Less home code more F3
This commit is contained in:
@@ -38,9 +38,9 @@ project/
|
||||
- **Assets statiques** — servis directement depuis `public/assets/`, laissés au serveur HTTP / reverse proxy.
|
||||
- **Upload** — `Web::receive()` avec contrôle de taille, puis validation MIME/dimensions côté modèle.
|
||||
- **Images** — normalisation des médias via `Image`, lecture/écriture via `Base::read()` / `Base::write()`.
|
||||
- **Markdown** — `Markdown::instance()->convert()` suivi d'un assainissement DOM ciblé et de la résolution des images média.
|
||||
- **Markdown** — `Markdown::instance()->convert()` suivi d'un assainissement DOM strict (liste blanche de balises, attributs et protocoles) et de la résolution locale des images média.
|
||||
- **Slugs** — `Web::instance()->slug()`.
|
||||
- **Session / CSRF** — `$f3->set('JAR', …)`, `new Session(null, 'CSRF')`, hooks `beforeRoute()` sur les contrôleurs protégés, et petit pool de jetons côté session pour éviter les collisions multi-onglets.
|
||||
- **Session / CSRF** — `$f3->set('JAR', …)`, `new Session(null, 'CSRF')` pour s'appuyer sur la génération native F3, puis persistance d'un jeton unique en session pour qu'il reste valide entre le GET du formulaire et le POST suivant ; validation centralisée via `verifyCsrf()`.
|
||||
- **ORM** — `DB\SQL\Mapper` : `paginate()`, `copyfrom()`, `cast()`, `find()`.
|
||||
- **Erreurs** — gestion personnalisée en production via `ONERROR` + fallback HTML minimal sur erreur fatale.
|
||||
|
||||
@@ -92,6 +92,10 @@ app.trusted_proxies=127.0.0.1,::1,172.16.0.0/12
|
||||
|
||||
Seuls ces proxies sont autorisés à influencer `X-Forwarded-Proto` / `Forwarded`.
|
||||
|
||||
### À propos du CSRF F3
|
||||
|
||||
F3 sait générer un jeton via `new Session(null, 'CSRF')`, mais ce jeton est produit à l'instanciation de la requête. Le projet s'en sert donc comme source native F3, puis le persiste explicitement en session pour garder un jeton stable entre l'affichage d'un formulaire et sa soumission.
|
||||
|
||||
## Développement local
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
0
docker/entrypoint.sh
Executable file → Normal file
0
docker/entrypoint.sh
Executable file → Normal file
Reference in New Issue
Block a user