Informations : éditeur visuel à la place de la syntaxe Markdown

La barre d'outils écrivait des étoiles dans un textarea : l'adhérent qui
rédigeait devait connaître une syntaxe pour obtenir du gras. On édite
désormais comme dans un traitement de texte — on sélectionne, on clique sur
G, le texte devient gras à l'écran.

Le format stocké ne change pas d'un octet. C'est tout l'enjeu : le WYSIWYG
porte sur la saisie, pas sur le stockage. À chaque frappe, le navigateur
retraverse ce qu'il a édité (serializeToRichText) et n'en garde que le gras,
l'italique, les listes, les sous-titres et les liens ; le balisage part dans
un champ caché et le serveur le ré-analyse avec l'analyseur existant. Le HTML
de l'éditeur ne quitte jamais la page, donc aucun assainisseur HTML n'entre
dans le produit — le risque n'a jamais été la surface d'édition, mais la base.

- rich-text-dom.ts : sérialiseur pur, testable sans navigateur (MinimalNode).
  Il lit aussi le gras codé en style CSS, pour qu'un brouillon collé depuis
  Google Docs ne perde pas sa mise en forme ; le style explicite l'emporte sur
  la balise, sinon le <b style="font-weight:normal"> dont Docs enveloppe tout
  document mettrait le texte entier en gras.
- Un collage passe par DOMParser, dont le document est inerte : un <img
  onerror> collé ne s'exécute pas. Seul du HTML produit par nous est réinjecté.
- L'italique s'écrit _ainsi_ : **_x_** est lisible par l'analyseur là où ***x***
  serait ambigu. Un tiret bas au milieu d'un mot n'ouvre rien.
- saveInformation normalise les CRLF que l'encodage des formulaires introduit.

Vérifié au navigateur contre une base réelle : aller-retour identique après
enregistrement, et un collage hostile (script, img onerror, iframe, style,
lien javascript:) ressort en texte structuré sans qu'une seule alerte parte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
This commit is contained in:
Claude committed 2026-09-15 06:37:52 +00:00
1 parent 90b121a3c1
commit cc33cf5fbc
10 files changed
+956 -91

No files matched your search

+44
View File
@@ -277,6 +277,7 @@ describe("Informations — rien n'est rendu en HTML brut", () => {
"../src/lib/rich-text.ts",
"../src/components/InformationCard.tsx",
"../src/app/backend/informations/InformationForm.tsx",
"../src/components/RichTextEditor.tsx",
"../src/app/backend/espace/informations/page.tsx",
];
@@ -320,3 +321,46 @@ describe("Boucles de fond — jamais dans le bundle edge", () => {
assert.ok(worker.includes('process.env.MAIL_WORKER !== "off"'));
});
});
describe("Éditeur visuel — le HTML ne quitte jamais la page", () => {
const editor = readFileSync(new URL("../src/components/RichTextEditor.tsx", import.meta.url), "utf8");
const form = readFileSync(new URL("../src/app/backend/informations/InformationForm.tsx", import.meta.url), "utf8");
// L'édition est visuelle, mais ce qui part au serveur reste le format
// balisé restreint : c'est tout l'arrangement qui permet un éditeur
// confortable sans stocker de HTML tiers.
it("le formulaire envoie le balisage sérialisé, jamais du HTML", () => {
assert.ok(form.includes('<input type="hidden" name="body"'), "le corps doit voyager en champ caché");
assert.ok(!form.includes("innerHTML"), "le formulaire ne manipule pas de HTML");
assert.ok(editor.includes("serializeToRichText("), "l'éditeur doit resérialiser ce qu'il édite");
});
it("seul du HTML produit par nous est injecté dans la zone d'édition", () => {
// Toute écriture de HTML dans l'éditeur passe par notre générateur : jamais
// une chaîne venue du presse-papier ou du serveur.
for (const call of editor.match(/innerHTML\s*=\s*[^;\n]+/g) ?? []) {
assert.ok(call.includes("richTextToEditorHtml("), `HTML injecté sans générateur : ${call}`);
}
for (const call of editor.match(/insertHTML"[^)]*\)/g) ?? []) {
assert.ok(call.includes("richTextToEditorHtml("), `HTML inséré sans générateur : ${call}`);
}
});
it("le collage est analysé dans un document inerte", () => {
// `DOMParser` n'exécute rien et ne charge aucune ressource, contrairement
// à une affectation d'`innerHTML` — même sur un nœud détaché, un
// `<img onerror>` peut s'y déclencher.
assert.ok(editor.includes('getData("text/html")'), "le collage HTML doit être traité");
assert.ok(editor.includes("new DOMParser().parseFromString("), "le HTML collé doit passer par DOMParser");
const pasteIndex = editor.indexOf('getData("text/html")');
const parserIndex = editor.indexOf("new DOMParser().parseFromString(");
assert.ok(parserIndex > pasteIndex, "le HTML collé doit être analysé avant toute réinjection");
});
it("le sérialiseur écarte les balises exécutables", () => {
const serializer = readFileSync(new URL("../src/lib/rich-text-dom.ts", import.meta.url), "utf8");
for (const tag of ["SCRIPT", "STYLE", "IFRAME", "OBJECT"]) {
assert.ok(serializer.includes(`"${tag}"`), `${tag} devrait être explicitement écarté`);
}
});
});