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
@@ -79,6 +79,7 @@ from .slugify import slugify
from .supports.create_support import create_support
from .supports.delete_support import delete_support
from .supports.list_supports import list_supports
from .supports.support_dir import support_dir
from .supports.support_meta import support_meta
from .table_name_for import table_name_for
@@ -115,6 +116,7 @@ __all__ = [
"set_scorm_version",
"create_support",
"list_supports",
"support_dir",
"support_meta",
"delete_support",
"ONBOARDING_TYPES",
+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)
+2
View File
@@ -8,9 +8,11 @@ from . import ( # noqa: F401 - enregistre les routes definies dans chaque modul
document_edit,
document_element_add,
document_element_delete,
document_element_download_attachment,
document_element_move,
document_element_move_to_page,
document_element_update,
document_element_upload_attachment,
document_new,
document_page_add,
document_page_delete,
@@ -0,0 +1,30 @@
import os
from flask import send_from_directory
from werkzeug.exceptions import NotFound
from werkzeug.wrappers import Response
import db
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/<int:element_id>/download-attachment")
def document_element_download_attachment(slug: str, element_id: int) -> Response:
"""Sert le fichier joint à un bouton (voir
document_element_upload_attachment.py) sous son nom d'origine
(`download_name`), jamais sous son nom de stockage anonymisé
(`attachment_stored_name`, un UUID)."""
element = document_engine.get_document_element(slug, element_id)
if element is None:
raise NotFound
stored_name = str(element["attributes"].get("attachment_stored_name", ""))
original_filename = str(element["attributes"].get("attachment_filename", ""))
if not stored_name:
raise NotFound
return send_from_directory(
os.path.join(db.support_dir(slug), "attachments"),
stored_name,
as_attachment=True,
download_name=original_filename or stored_name,
)
@@ -0,0 +1,54 @@
import os
import uuid
from typing import Any
from flask import jsonify, request
from werkzeug.wrappers import Response
import db
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/<int:element_id>/upload-attachment", methods=["POST"])
def document_element_upload_attachment(slug: str, element_id: int) -> Response | tuple[Response, int]:
"""Joint un fichier téléchargeable à un bouton (voir _render_button,
document_engine/rendering/render_document_element.py) — mirroir de
routes/uploads/upload_file.py côté jeu, mais stocké sous le dossier du
SUPPORT (db.support_dir) et réservé au kind "bouton" (un fichier joint
n'a de sens que pour déclencher un téléchargement au clic)."""
element = document_engine.get_document_element(slug, element_id)
if element is None:
return jsonify({"error": "élément introuvable"}), 404
if element["kind"] != "bouton":
return jsonify({"error": "seul un bouton peut recevoir un fichier joint"}), 400
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "Aucun fichier reçu"}), 400
original_filename = f.filename
ext = "".join(c for c in os.path.splitext(original_filename)[1].lower() if c.isalnum() or c == ".")[:10]
stored_name = uuid.uuid4().hex + ext
attachments_dir = os.path.join(db.support_dir(slug), "attachments")
os.makedirs(attachments_dir, exist_ok=True)
f.save(os.path.join(attachments_dir, stored_name))
attributes = {
**element["attributes"],
"attachment_stored_name": stored_name,
"attachment_filename": original_filename,
}
document_engine.update_document_element_attributes(slug, element_id, attributes)
element = db.assert_not_none(
document_engine.get_document_element(slug, element_id),
"element_id verifie present juste au-dessus, aucune suppression concurrente possible entre-temps ici",
)
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
for el in document_engine.list_document_elements(slug, element["page_id"]):
elements_by_parent.setdefault(el["parent_id"], []).append(el)
return jsonify(
{
"ok": True,
"attributes": element["attributes"],
"rendered_html": document_engine.render_document_element(element, elements_by_parent),
}
)
+2
View File
@@ -306,6 +306,8 @@ body.objectEditBody > main.content {
.docLibIcon--bouton::before { content: "⬚"; }
.docLibIcon--liste_puces::before { content: "☰"; }
.docLibIcon--liste_numerotee::before { content: "①"; }
.docLibIcon--badge::before { content: "◉"; }
.docLibIcon--carte::before { content: "▭"; }
.docLibIcon--titre::before {
content: "T";
+74
View File
@@ -98,6 +98,16 @@ function forgeDocApiMoveToPage(id, targetPageId) {
}).then((r) => r.json());
}
function forgeDocApiUploadAttachment(id, file) {
// Multipart, jamais du JSON comme forgeDocApiUpdate ci-dessus — un
// fichier ne se sérialise pas dans le corps JSON d'un PATCH classique
// (voir routes/document/document_element_upload_attachment.py).
const body = new FormData();
body.set('file', file);
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/upload-attachment`, { method: 'POST', body })
.then((r) => r.json());
}
function forgeDocApiRender() {
const url = `/document/${window.forgeDocState.slug}/render?page_id=${window.forgeDocState.activePageId}`;
return fetch(url).then((r) => r.json());
@@ -778,6 +788,14 @@ function forgeDocBindCanvasInteractions() {
const elementId = Number.parseInt(dom.dataset.elementId, 10);
dom.addEventListener('click', (e) => {
e.stopPropagation();
// En Aperçu, un bouton portant un fichier joint déclenche son
// téléchargement plutôt que la sélection (réservée à l'édition) —
// même principe que les mini-jeux, jouables uniquement en Aperçu
// (voir static/document/document-editor.css, .docEditor3--preview).
if (window.forgeDocState.previewMode && dom.dataset.attachmentFilename) {
window.location.href = `/document/${window.forgeDocState.slug}/elements/${elementId}/download-attachment`;
return;
}
forgeDocSelectElement(elementId);
});
dom.setAttribute('draggable', 'true');
@@ -1649,24 +1667,78 @@ function forgeDocRenderImageProps(panel, el) {
${forgeDocPropsHeader('image')}
<div class="docField"><label for="docImageSrc">URL de l'image</label><input type="text" id="docImageSrc" value="${a.src || ''}" placeholder="https://..."></div>
<div class="docField"><label for="docImageAlt">Texte alternatif</label><input type="text" id="docImageAlt" value="${a.alt || ''}"></div>
<div class="docField"><label for="docImageSvg">Code SVG (prioritaire sur l'URL si renseigné)</label><textarea id="docImageSvg" rows="4">${forgeDocEscapeHtml(a.svg_markup || '')}</textarea></div>
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docImageSrc').addEventListener('change', (e) => patch({ src: e.target.value.trim() }));
document.getElementById('docImageAlt').addEventListener('change', (e) => patch({ alt: e.target.value }));
document.getElementById('docImageSvg').addEventListener('change', (e) => patch({ svg_markup: e.target.value.trim() }));
}
function forgeDocRenderButtonProps(panel, el) {
const a = el.attributes;
const attachmentStatus = a.attachment_filename
? `Fichier joint : ${forgeDocEscapeHtml(a.attachment_filename)}`
: 'Aucun fichier joint';
panel.innerHTML = `
${forgeDocPropsHeader('bouton')}
<div class="docField"><label for="docButtonLabel">Texte du bouton</label><input type="text" id="docButtonLabel" value="${a.label || ''}"></div>
<div class="docField"><label for="docButtonTarget">Cible (URL ou ancre)</label><input type="text" id="docButtonTarget" value="${a.target || ''}"></div>
<div class="docField">
<span class="docFieldLabel">Fichier téléchargeable</span>
<div id="docButtonAttachmentStatus">${attachmentStatus}</div>
<input type="file" id="docButtonAttachmentInput">
</div>
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docButtonLabel').addEventListener('change', (e) => patch({ label: e.target.value }));
document.getElementById('docButtonTarget').addEventListener('change', (e) => patch({ target: e.target.value.trim() }));
document.getElementById('docButtonAttachmentInput').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
forgeDocApiUploadAttachment(el.id, file).then((result) => {
if (result.error) { alert(result.error); return; }
el.attributes = result.attributes;
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
const dom = document.querySelector(`[data-element-id="${el.id}"]`);
if (dom && result.rendered_html) {
const wrapper = document.createElement('div');
wrapper.innerHTML = result.rendered_html;
dom.replaceWith(wrapper.firstElementChild);
forgeDocBindCanvasInteractions();
forgeDocApplySelectionClass();
}
});
});
}
function forgeDocRenderBadgeProps(panel, el) {
const a = el.attributes;
panel.innerHTML = `
${forgeDocPropsHeader('étiquette')}
<div class="docField"><label for="docBadgeContent">Contenu</label><input type="text" id="docBadgeContent" value="${forgeDocEscapeHtml(a.content || '')}"></div>
${forgeDocDeleteButtonHtml()}
`;
document.getElementById('docBadgeContent').addEventListener('change', (e) => {
forgeDocUpdateAttributes(el.id, { ...el.attributes, content: e.target.value });
});
}
function forgeDocRenderCarteProps(panel, el) {
const a = el.attributes;
panel.innerHTML = `
${forgeDocPropsHeader('carte')}
<div class="docField"><label for="docCardLabel">Repère (lettre, chiffre...)</label><input type="text" id="docCardLabel" value="${forgeDocEscapeHtml(a.label || '')}"></div>
<div class="docField"><label for="docCardTitle">Titre</label><input type="text" id="docCardTitle" value="${forgeDocEscapeHtml(a.title || '')}"></div>
<div class="docField"><label for="docCardDescription">Description</label><textarea id="docCardDescription" rows="3">${forgeDocEscapeHtml(a.description || '')}</textarea></div>
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docCardLabel').addEventListener('change', (e) => patch({ label: e.target.value }));
document.getElementById('docCardTitle').addEventListener('change', (e) => patch({ title: e.target.value }));
document.getElementById('docCardDescription').addEventListener('change', (e) => patch({ description: e.target.value }));
}
function forgeDocRenderListItemHtml(item, index) {
@@ -2777,6 +2849,8 @@ function forgeDocRenderProps(el) {
else if (el.kind === 'image') forgeDocRenderImageProps(panel, el);
else if (el.kind === 'bouton') forgeDocRenderButtonProps(panel, el);
else if (el.kind === 'liste_puces' || el.kind === 'liste_numerotee') forgeDocRenderListProps(panel, el);
else if (el.kind === 'badge') forgeDocRenderBadgeProps(panel, el);
else if (el.kind === 'carte') forgeDocRenderCarteProps(panel, el);
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
+122
View File
@@ -225,3 +225,125 @@ def test_liste_puces_with_no_items_renders_an_empty_list(tmp_support_slug_cleanu
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert html == f'<ul class="docList" data-element-id="{element_id}" data-kind="liste_puces"></ul>'
def test_badge_default_attributes_and_render(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "badge", page_id=page_id)
element = doc_engine.get_document_element(slug, element_id)
assert element is not None
assert element["attributes"] == {"content": "Étiquette"}
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert html == f'<div class="docBadge" data-element-id="{element_id}" data-kind="badge">Étiquette</div>'
def test_badge_escapes_content(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "badge", page_id=page_id)
doc_engine.update_document_element_attributes(slug, element_id, {"content": "<script>alert(1)</script>"})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "<script>" not in html
assert "&lt;script&gt;" in html
def test_carte_default_attributes_and_render(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "carte", page_id=page_id)
element = doc_engine.get_document_element(slug, element_id)
assert element is not None
assert element["attributes"] == {
"label": "A",
"title": "Titre de la carte",
"description": "Description de la carte.",
}
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert html == (
f'<div class="docCard" data-element-id="{element_id}" data-kind="carte">'
f'<div class="docCardLabel">A</div>'
f'<div class="docCardTitle">Titre de la carte</div>'
f'<div class="docCardDescription">Description de la carte.</div>'
f"</div>"
)
def test_carte_escapes_all_fields(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "carte", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"label": "<b>", "title": "<i>", "description": "<script>alert(1)</script>"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "<script>" not in html
assert "<b>" not in html
assert "<i>" not in html
def test_image_default_attributes_include_empty_svg_markup(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
element = doc_engine.get_document_element(slug, element_id)
assert element is not None
assert element["attributes"] == {"src": "", "alt": "", "svg_markup": ""}
def test_image_with_svg_markup_takes_priority_over_src(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
doc_engine.update_document_element_attributes(
slug,
element_id,
{"src": "https://exemple.test/photo.png", "alt": "", "svg_markup": '<svg><circle cx="5" cy="5" r="3"/></svg>'},
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "<img" not in html
assert f'<div class="docImage" data-element-id="{element_id}" data-kind="image">' in html
assert '<circle cx="5" cy="5" r="3">' in html or '<circle cx="5" cy="5" r="3"/>' in html
def test_image_svg_markup_strips_script_tag(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
malicious = '<svg onload="alert(1)"><script>alert(2)</script><circle cx="1" cy="1" r="1" onclick="alert(3)"/></svg>'
doc_engine.update_document_element_attributes(slug, element_id, {"src": "", "alt": "", "svg_markup": malicious})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "<script>" not in html
assert "onload" not in html
assert "onclick" not in html
assert "alert(" not in html
def test_bouton_default_attributes_include_empty_attachment_fields(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
element = doc_engine.get_document_element(slug, element_id)
assert element is not None
assert element["attributes"] == {
"label": "Bouton",
"target": "",
"attachment_stored_name": "",
"attachment_filename": "",
}
def test_bouton_with_attachment_filename_renders_data_attribute(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
doc_engine.update_document_element_attributes(
slug,
element_id,
{
"label": "Télécharger",
"target": "",
"attachment_stored_name": "abc123.pdf",
"attachment_filename": "fiche-consignes.pdf",
},
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert 'data-attachment-filename="fiche-consignes.pdf"' in html
+56
View File
@@ -8,6 +8,7 @@ pages elles-mêmes) — _page_id() renvoie l'id de la page par défaut
("Page 1") créée avec chaque support de test, réutilisé par toutes les
routes d'élément ci-dessous qui exigent désormais un page_id explicite."""
import io
from typing import Any
from flask.testing import FlaskClient
@@ -344,3 +345,58 @@ def test_cannot_open_another_owners_support(client: FlaskClient, user_client: Fl
assert client.post(f"/document/{victim_slug}/delete").status_code == 403
finally:
db.delete_support(victim_slug)
def _add_bouton(client: FlaskClient, support: str) -> int:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "bouton", "page_id": _page_id(support)})
return int(resp.get_json()["id"])
def test_upload_attachment_stores_file_and_updates_button_attributes(client: FlaskClient, support: str) -> None:
element_id = _add_bouton(client, support)
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-attachment",
data={"file": (io.BytesIO(b"%PDF-1.4 fake pdf content"), "fiche-consignes.pdf")},
content_type="multipart/form-data",
)
assert resp.status_code == 200
payload = resp.get_json()
assert payload["attributes"]["attachment_filename"] == "fiche-consignes.pdf"
assert payload["attributes"]["attachment_stored_name"]
assert 'data-attachment-filename="fiche-consignes.pdf"' in payload["rendered_html"]
def test_upload_attachment_rejects_a_non_bouton_element(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)})
element_id = resp.get_json()["id"]
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-attachment",
data={"file": (io.BytesIO(b"peu importe"), "x.pdf")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
def test_upload_attachment_rejects_a_missing_file(client: FlaskClient, support: str) -> None:
element_id = _add_bouton(client, support)
resp = client.post(f"/document/{support}/elements/{element_id}/upload-attachment", data={})
assert resp.status_code == 400
def test_download_attachment_serves_the_file_under_its_original_name(client: FlaskClient, support: str) -> None:
element_id = _add_bouton(client, support)
client.post(
f"/document/{support}/elements/{element_id}/upload-attachment",
data={"file": (io.BytesIO(b"%PDF-1.4 fake pdf content"), "fiche-consignes.pdf")},
content_type="multipart/form-data",
)
resp = client.get(f"/document/{support}/elements/{element_id}/download-attachment")
assert resp.status_code == 200
assert resp.data == b"%PDF-1.4 fake pdf content"
assert "fiche-consignes.pdf" in resp.headers["Content-Disposition"]
def test_download_attachment_404s_when_nothing_was_uploaded(client: FlaskClient, support: str) -> None:
element_id = _add_bouton(client, support)
resp = client.get(f"/document/{support}/elements/{element_id}/download-attachment")
assert resp.status_code == 404
@@ -0,0 +1,71 @@
"""Nettoyage du SVG inline collé comme contenu d'image (voir
document_engine/rendering/sanitize_svg_markup.py) — chaque cas ici
reproduit une charge malveillante RÉELLE plutôt qu'une simple assertion
"pas de régression" (voir CLAUDE.md, exigence pour tout changement de
comportement lié à l'échappement/la sécurité)."""
from document_engine.rendering.sanitize_svg_markup import sanitize_svg_markup
def test_strips_script_tag_and_its_content() -> None:
result = sanitize_svg_markup("<svg><script>alert(document.cookie)</script></svg>")
assert "<script>" not in result
assert "alert(document.cookie)" not in result
def test_strips_event_handler_attributes() -> None:
result = sanitize_svg_markup('<svg onload="alert(1)"><circle onclick="alert(2)" cx="5" cy="5" r="3"/></svg>')
assert "onload" not in result
assert "onclick" not in result
assert "alert(" not in result
def test_strips_href_to_block_javascript_uri() -> None:
result = sanitize_svg_markup('<svg><a href="javascript:alert(1)"><circle cx="1" cy="1" r="1"/></a></svg>')
assert "javascript:" not in result
assert "<a" not in result
assert "href" not in result
def test_strips_foreignobject_and_embedded_html() -> None:
result = sanitize_svg_markup(
'<svg><foreignObject><body onload="alert(1)"><img src="x" onerror="alert(2)"></body></foreignObject></svg>'
)
assert "foreignObject".lower() not in result.lower()
assert "onerror" not in result
assert "alert(" not in result
def test_strips_style_attribute_and_style_tag() -> None:
result = sanitize_svg_markup(
'<svg><style>*{display:none}</style><circle style="fill:red" cx="1" cy="1" r="1"/></svg>'
)
assert "<style>" not in result
assert "style=" not in result
assert "display:none" not in result
def test_strips_use_tag_referencing_external_content() -> None:
result = sanitize_svg_markup('<svg><use href="https://evil.test/x.svg#payload"/></svg>')
assert "<use" not in result
assert "evil.test" not in result
def test_keeps_allowed_shape_and_presentation_attributes() -> None:
result = sanitize_svg_markup(
'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ff0000" stroke="#000"/></svg>'
)
assert "<svg" in result
assert "<circle" in result
assert 'cx="12"' in result
assert 'fill="#ff0000"' in result
assert 'stroke="#000"' in result
def test_self_closing_disallowed_tag_does_not_swallow_following_content() -> None:
result = sanitize_svg_markup('<svg><script/><circle cx="1" cy="1" r="1"/></svg>')
assert "<circle" in result
def test_empty_markup_returns_empty_string() -> None:
assert sanitize_svg_markup("") == ""