Ajoute 4 mécanismes moteur manquants pour le thème sécurité incendie : étiquette, carte, image SVG inline, bouton avec pièce jointe

Contenu et mécanisme uniquement, aucun style ajouté (voir consigne du
24/09/2026) : deux nouveaux kinds de contenu (badge/carte, rendu en
div brutes sans CSS), un mode SVG inline pour l'image (svg_markup,
nettoyé par un nouveau sanitizer allow-list avant chaque rendu) et un
fichier téléchargeable joignable à un bouton (upload/download routes,
stockage sous db.support_dir). Le futur système de templates portera
l'habillage visuel de ces éléments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-24 08:12:29 +02:00
co-authored by Claude Sonnet 5
parent e7c6ed7159
commit a34bcf4159
15 changed files with 668 additions and 18 deletions
+2
View File
@@ -77,6 +77,7 @@ from .pages.list_document_pages import list_document_pages
from .pages.move_document_page import move_document_page
from .pages.update_document_page import update_document_page
from .rendering.render_document_element import render_document, render_document_element
from .rendering.sanitize_svg_markup import sanitize_svg_markup
__all__ = [
"CARD_MODES",
@@ -121,6 +122,7 @@ __all__ = [
"sanitize_mots_config",
"sanitize_quiz_config",
"sanitize_scenario_config",
"sanitize_svg_markup",
"update_document_element_attributes",
"update_document_page",
]
+22 -3
View File
@@ -11,7 +11,7 @@ from .mots_config import DEFAULT_MOTS_CONFIG
from .quiz_config import DEFAULT_QUIZ_CONFIG
from .scenario_config import DEFAULT_SCENARIO_CONFIG
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton", "liste_puces", "liste_numerotee")
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton", "liste_puces", "liste_numerotee", "badge", "carte")
MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones")
# "row" n'apparaît jamais dans la bibliothèque (créé implicitement par le
@@ -30,6 +30,8 @@ ELEMENT_KIND_LABELS: dict[str, str] = {
"bouton": "Bouton",
"liste_puces": "Liste à puces",
"liste_numerotee": "Liste numérotée",
"badge": "Étiquette",
"carte": "Carte",
"quiz": "Quiz",
"association": "Association",
"memory": "Memory",
@@ -56,15 +58,32 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
if kind == "paragraphe":
return {"content": "Nouveau paragraphe de texte.", "style": "paragraphe", **_TEXT_DEFAULTS}
if kind == "image":
return {"src": "", "alt": ""}
# svg_markup (optionnel) prend le pas sur src au rendu (voir
# render_document_element._render_image) — un contenu vectoriel
# dessiné/collé directement plutôt qu'un fichier hébergé.
return {"src": "", "alt": "", "svg_markup": ""}
if kind == "bouton":
return {"label": "Bouton", "target": ""}
# attachment_stored_name/attachment_filename (optionnels) : un
# fichier téléchargeable joint au bouton (voir routes/document/
# document_element_upload_attachment.py), indépendant de `target`
# qui reste réservé à la navigation (URL/ancre). Jamais les deux
# à la fois côté UI (voir document-editor.js), mais rien ne
# l'empêche structurellement ici.
return {"label": "Bouton", "target": "", "attachment_stored_name": "", "attachment_filename": ""}
if kind in ("liste_puces", "liste_numerotee"):
# Une seule et même structure d'attributs pour les deux kinds —
# "ordonnée ou non" se lit directement sur le kind au moment du
# rendu (voir render_document_element._render_list), jamais un
# attribut "ordered" redondant à tenir synchronisé avec le kind.
return {"items": ["Premier élément", "Deuxième élément"]}
if kind == "badge":
return {"content": "Étiquette"}
if kind == "carte":
# Contenu pur (lettre/repère court + titre + description) — sans
# aucun choix de couleur/forme, laissé au futur système de
# templates (voir consigne du 24/09/2026 : moteur = contenu et
# mécanisme uniquement, jamais de style).
return {"label": "A", "title": "Titre de la carte", "description": "Description de la carte."}
if kind == "row":
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
if kind == "quiz":
+21 -11
View File
@@ -6,12 +6,17 @@ attributs par défaut posés à la création de chaque type.
## `CONTENT_KINDS: tuple[str, ...]`
`("titre", "paragraphe", "image", "bouton", "liste_puces",
"liste_numerotee")` — éléments du flux, peuvent être top-niveau ou
enfants d'une rangée. `"liste_puces"`/`"liste_numerotee"` partagent la
même structure d'attributs (`items`) ; c'est le `kind` lui-même qui
décide `<ul>` ou `<ol>` au rendu (voir
"liste_numerotee", "badge", "carte")` — éléments du flux, peuvent être
top-niveau ou enfants d'une rangée. `"liste_puces"`/`"liste_numerotee"`
partagent la même structure d'attributs (`items`) ; c'est le `kind`
lui-même qui décide `<ul>` ou `<ol>` au rendu (voir
`document_engine/rendering/render_document_element.py::_render_list`),
jamais un attribut `ordered` redondant.
jamais un attribut `ordered` redondant. `"badge"` et `"carte"` sont du
contenu PUR, sans aucun attribut de style/couleur/forme (décision du
24/09/2026 : le moteur ne porte que contenu et mécanisme, tout habillage
visuel revient à un futur système de templates) — voir aussi le mode SVG
inline de `"image"` (`svg_markup`) et la pièce jointe téléchargeable de
`"bouton"` (`attachment_filename`), ci-dessous.
## `MINIGAME_KINDS: tuple[str, ...]`
`("quiz", "association", "memory", "mots", "scenario", "zones")` —
@@ -35,12 +40,17 @@ Attributs posés à la création d'un élément de ce type (voir
`document_engine/elements/add_document_element.py`).
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` :
texte (`content/style` + `bold/italic/underline/align/color`),
image (`src/alt`), bouton (`label/target`), liste à puces/numérotée
(`items`, une liste de chaînes), rangée (`gap/align/justify`),
quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), association
(`DEFAULT_ASSOCIATION_CONFIG`, voir `association_config.py`), memory
(`DEFAULT_MEMORY_CONFIG`, voir `memory_config.py`), autre mini-jeu
(`theme_color`), ou `{}` pour un `kind` inconnu.
image (`src/alt/svg_markup` — `svg_markup` prend le pas sur `src` au
rendu, voir rendering.md), bouton (`label/target/
attachment_stored_name/attachment_filename` — la pièce jointe est
indépendante de `target`, réservé à la navigation), liste à
puces/numérotée (`items`, une liste de chaînes), badge (`content`),
carte (`label/title/description`, contenu pur sans couleur/forme),
rangée (`gap/align/justify`), quiz (`DEFAULT_QUIZ_CONFIG`, voir
`quiz_config.py`), association (`DEFAULT_ASSOCIATION_CONFIG`, voir
`association_config.py`), memory (`DEFAULT_MEMORY_CONFIG`, voir
`memory_config.py`), autre mini-jeu (`theme_color`), ou `{}` pour un
`kind` inconnu.
- **Exceptions** : aucune.
## `quiz_config.py` — modèle de données du mini-jeu Quiz
@@ -64,6 +64,17 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"]
svg_markup = str(a.get("svg_markup", "")).strip()
if svg_markup:
# Contenu vectoriel dessiné/collé par le créateur plutôt qu'un
# fichier hébergé — prioritaire sur `src` (voir
# element_kind_labels.element_default_attributes). Nettoyé à
# CHAQUE rendu (jamais seulement à l'écriture) par sanitize_svg_markup,
# même défense en profondeur que html.escape sur les autres kinds.
from .sanitize_svg_markup import sanitize_svg_markup
sanitized = sanitize_svg_markup(svg_markup)
return f'<div class="docImage" data-element-id="{el["id"]}" data-kind="image">{sanitized}</div>'
src = html_lib.escape(str(a.get("src", "")))
alt = html_lib.escape(str(a.get("alt", "")))
if not src:
@@ -93,9 +104,37 @@ def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, lis
label = html_lib.escape(str(a.get("label", "Bouton")))
target = html_lib.escape(str(a.get("target", "")))
target_attr = f' data-target="{target}"' if target else ""
# `data-attachment-filename` sert UNIQUEMENT de marqueur mécanique : un
# fichier a bien été joint (voir routes/document/
# document_element_upload_attachment.py). L'URL de téléchargement
# elle-même n'est jamais construite ici (ce renderer ne connaît pas le
# slug du support) — static/document/js/document-editor.js l'assemble
# à partir de `data-element-id` + FORGE_DOCUMENT.slug, même principe
# que le reste des appels AJAX de l'éditeur.
attachment_filename = html_lib.escape(str(a.get("attachment_filename", "")))
attachment_attr = f' data-attachment-filename="{attachment_filename}"' if attachment_filename else ""
return (
f'<button type="button" class="docButton" data-element-id="{el["id"]}" data-kind="bouton"{target_attr}>'
f"{label}</button>"
f'<button type="button" class="docButton" data-element-id="{el["id"]}" '
f'data-kind="bouton"{target_attr}{attachment_attr}>{label}</button>'
)
def _render_badge(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
content = html_lib.escape(str(el["attributes"].get("content", "")))
return f'<div class="docBadge" data-element-id="{el["id"]}" data-kind="badge">{content}</div>'
def _render_carte(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"]
label = html_lib.escape(str(a.get("label", "")))
title = html_lib.escape(str(a.get("title", "")))
description = html_lib.escape(str(a.get("description", "")))
return (
f'<div class="docCard" data-element-id="{el["id"]}" data-kind="carte">'
f'<div class="docCardLabel">{label}</div>'
f'<div class="docCardTitle">{title}</div>'
f'<div class="docCardDescription">{description}</div>'
f"</div>"
)
@@ -514,6 +553,8 @@ _RENDERERS = {
"bouton": _render_button,
"liste_puces": _render_list,
"liste_numerotee": _render_list,
"badge": _render_badge,
"carte": _render_carte,
"quiz": _render_quiz,
"association": _render_association,
"memory": _render_memory,
+38 -2
View File
@@ -30,8 +30,25 @@ regroupement à chaque appel.
récursivement.
- **Texte** (`titre`/`paragraphe`) : `<div>` stylé selon `style` (préréglage
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide.
- **Bouton** : `<button>` avec son `label` et un `data-target` optionnel.
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide — OU, si
`attributes["svg_markup"]` est non vide (prioritaire sur `src`), un
`<div>` portant directement ce fragment SVG nettoyé par
`sanitize_svg_markup` (voir `sanitize_svg_markup.py` ci-dessous) : un
contenu vectoriel dessiné/collé par le créateur plutôt qu'un fichier
hébergé, sans aucun style qui lui soit propre.
- **Bouton** : `<button>` avec son `label`, un `data-target` optionnel
(navigation) et un `data-attachment-filename` optionnel — marqueur
mécanique posé quand un fichier a été joint (voir
`routes/document/document_element_upload_attachment.py`), jamais
l'URL de téléchargement elle-même (ce renderer ne connaît pas le slug
du support ; `static/document/js/document-editor.js` l'assemble à
partir de `data-element-id` + `FORGE_DOCUMENT.slug`, même principe que
le reste des appels AJAX de l'éditeur).
- **Étiquette** (`badge`) : `<div>` portant `attributes["content"]`
échappé — contenu pur, aucun attribut de style.
- **Carte** (`carte`) : `<div>` composé de trois blocs enfants
(`label`/`title`/`description`, tous échappés) — contenu pur, aucune
couleur/forme choisie ici (voir `element_kind_labels.md`).
- **Liste à puces/numérotée** (`liste_puces`/`liste_numerotee`) :
`<ul>` ou `<ol>` selon le `kind` (fonction privée `_render_list`,
partagée par les deux) — un `<li>` par entrée de `attributes["items"]`.
@@ -146,3 +163,22 @@ regroupement à chaque appel.
du type (voir `document_engine/labels/element_kind_labels.py`) —
emplacement réservé, formulaire de contenu dédié hors périmètre de
cette passe.
## `sanitize_svg_markup.py` — nettoyage du contenu SVG inline d'une image
### `sanitize_svg_markup(markup: str) -> str`
Nettoie un fragment SVG selon une LISTE BLANCHE de balises/attributs
(`_ALLOWED_TAGS`/`_ALLOWED_ATTRS`, privées) — construit sur
`html.parser.HTMLParser` (tokenizer de balises pur, sans DTD ni
résolution d'entité externe) plutôt qu'un analyseur XML, qui resterait
exposé aux attaques classiques d'entité externe sur une entrée non
fiable. Toute balise absente de la liste blanche (`<script>`,
`<foreignObject>`, `<a>`, `<use>`, `<image>`...) disparaît AVEC son
contenu ; tout attribut absent (`on*`, `style`, `href`/`xlink:href`,
`class`...) disparaît seul, la balise porteuse étant conservée si elle
est autorisée. Appelé à CHAQUE rendu (`_render_image`), jamais
seulement à l'écriture — même défense en profondeur que
`html.escape` sur les autres kinds.
- **Retour** : le fragment SVG nettoyé, sûr à insérer tel quel dans le
HTML rendu.
- **Exceptions** : aucune.
@@ -0,0 +1,129 @@
"""Nettoyage d'un fragment SVG saisi/collé par le créateur comme contenu
d'une image (voir render_document_element._render_image) — jamais un
rendu direct de `attributes["svg_markup"]`, qui exposerait une injection
XSS triviale (`<script>`, `onload="..."`, `href="javascript:..."`).
Construit sur `html.parser.HTMLParser` (analyseur de balises pur, sans
DTD ni résolution d'entité externe) plutôt que sur un analyseur XML
(`xml.etree.ElementTree`), qui resterait exposé aux attaques classiques
d'entité externe/"milliard de rires" sur une entrée non fiable."""
from html.parser import HTMLParser
_ALLOWED_TAGS = {
"svg",
"path",
"circle",
"rect",
"line",
"polyline",
"polygon",
"ellipse",
"g",
"defs",
"lineargradient",
"radialgradient",
"stop",
"title",
"desc",
}
_ALLOWED_ATTRS = {
"viewbox",
"width",
"height",
"fill",
"stroke",
"stroke-width",
"stroke-linecap",
"stroke-linejoin",
"stroke-dasharray",
"opacity",
"fill-opacity",
"stroke-opacity",
"fill-rule",
"d",
"cx",
"cy",
"r",
"rx",
"ry",
"x",
"y",
"x1",
"y1",
"x2",
"y2",
"points",
"transform",
"offset",
"stop-color",
"stop-opacity",
}
class _SvgSanitizer(HTMLParser):
"""Reconstruit un fragment SVG balise par balise, en ne conservant que
les éléments/attributs de la liste blanche — jamais de liste noire
(une balise/un attribut absent de la liste blanche est TOUJOURS
supprimé, y compris un futur ajout du format SVG qu'on n'aurait pas
anticipé ici)."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.output: list[str] = []
self._skip_depth = 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self._open_tag(tag, attrs, self_closing=False)
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self._open_tag(tag, attrs, self_closing=True)
def _open_tag(self, tag: str, attrs: list[tuple[str, str | None]], *, self_closing: bool) -> None:
tag = tag.lower()
if self._skip_depth or tag not in _ALLOWED_TAGS:
# Une balise auto-fermante (ex. <script/>) n'aura jamais de
# handle_endtag correspondant : incrémenter ici ferait fuir
# tout le reste du document dans un skip permanent.
if not self_closing:
self._skip_depth += 1
return
kept = [(name.lower(), value) for name, value in attrs if name.lower() in _ALLOWED_ATTRS]
attrs_html = "".join(f' {name}="{_escape_attr(value or "")}"' for name, value in kept)
self.output.append(f"<{tag}{attrs_html}{'/>' if self_closing else '>'}")
def handle_endtag(self, tag: str) -> None:
if self._skip_depth:
self._skip_depth -= 1
return
if tag.lower() in _ALLOWED_TAGS:
self.output.append(f"</{tag.lower()}>")
def handle_data(self, data: str) -> None:
if not self._skip_depth:
self.output.append(_escape_text(data))
def _escape_attr(value: str) -> str:
return value.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
def _escape_text(value: str) -> str:
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def sanitize_svg_markup(markup: str) -> str:
"""Nettoie `markup` selon la liste blanche `_ALLOWED_TAGS`/
`_ALLOWED_ATTRS` — toute balise/attribut absent de ces listes (y
compris `<script>`, `on*`, `style`, `href`/`xlink:href`,
`<foreignObject>`) est supprimé, jamais échappé tel quel.
- **Retour** : le fragment SVG nettoyé, sûr à insérer tel quel dans le
HTML rendu (jamais vide de sens : une balise inconnue disparaît
avec son contenu, une balise connue avec un attribut refusé perd
seulement cet attribut).
- **Exceptions** : aucune."""
sanitizer = _SvgSanitizer()
sanitizer.feed(markup)
sanitizer.close()
return "".join(sanitizer.output)