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:
co-authored by
Claude Sonnet 5
parent
e7c6ed7159
commit
a34bcf4159
@@ -79,6 +79,7 @@ from .slugify import slugify
|
|||||||
from .supports.create_support import create_support
|
from .supports.create_support import create_support
|
||||||
from .supports.delete_support import delete_support
|
from .supports.delete_support import delete_support
|
||||||
from .supports.list_supports import list_supports
|
from .supports.list_supports import list_supports
|
||||||
|
from .supports.support_dir import support_dir
|
||||||
from .supports.support_meta import support_meta
|
from .supports.support_meta import support_meta
|
||||||
from .table_name_for import table_name_for
|
from .table_name_for import table_name_for
|
||||||
|
|
||||||
@@ -115,6 +116,7 @@ __all__ = [
|
|||||||
"set_scorm_version",
|
"set_scorm_version",
|
||||||
"create_support",
|
"create_support",
|
||||||
"list_supports",
|
"list_supports",
|
||||||
|
"support_dir",
|
||||||
"support_meta",
|
"support_meta",
|
||||||
"delete_support",
|
"delete_support",
|
||||||
"ONBOARDING_TYPES",
|
"ONBOARDING_TYPES",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ from .pages.list_document_pages import list_document_pages
|
|||||||
from .pages.move_document_page import move_document_page
|
from .pages.move_document_page import move_document_page
|
||||||
from .pages.update_document_page import update_document_page
|
from .pages.update_document_page import update_document_page
|
||||||
from .rendering.render_document_element import render_document, render_document_element
|
from .rendering.render_document_element import render_document, render_document_element
|
||||||
|
from .rendering.sanitize_svg_markup import sanitize_svg_markup
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CARD_MODES",
|
"CARD_MODES",
|
||||||
@@ -121,6 +122,7 @@ __all__ = [
|
|||||||
"sanitize_mots_config",
|
"sanitize_mots_config",
|
||||||
"sanitize_quiz_config",
|
"sanitize_quiz_config",
|
||||||
"sanitize_scenario_config",
|
"sanitize_scenario_config",
|
||||||
|
"sanitize_svg_markup",
|
||||||
"update_document_element_attributes",
|
"update_document_element_attributes",
|
||||||
"update_document_page",
|
"update_document_page",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from .mots_config import DEFAULT_MOTS_CONFIG
|
|||||||
from .quiz_config import DEFAULT_QUIZ_CONFIG
|
from .quiz_config import DEFAULT_QUIZ_CONFIG
|
||||||
from .scenario_config import DEFAULT_SCENARIO_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")
|
MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones")
|
||||||
|
|
||||||
# "row" n'apparaît jamais dans la bibliothèque (créé implicitement par le
|
# "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",
|
"bouton": "Bouton",
|
||||||
"liste_puces": "Liste à puces",
|
"liste_puces": "Liste à puces",
|
||||||
"liste_numerotee": "Liste numérotée",
|
"liste_numerotee": "Liste numérotée",
|
||||||
|
"badge": "Étiquette",
|
||||||
|
"carte": "Carte",
|
||||||
"quiz": "Quiz",
|
"quiz": "Quiz",
|
||||||
"association": "Association",
|
"association": "Association",
|
||||||
"memory": "Memory",
|
"memory": "Memory",
|
||||||
@@ -56,15 +58,32 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
|
|||||||
if kind == "paragraphe":
|
if kind == "paragraphe":
|
||||||
return {"content": "Nouveau paragraphe de texte.", "style": "paragraphe", **_TEXT_DEFAULTS}
|
return {"content": "Nouveau paragraphe de texte.", "style": "paragraphe", **_TEXT_DEFAULTS}
|
||||||
if kind == "image":
|
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":
|
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"):
|
if kind in ("liste_puces", "liste_numerotee"):
|
||||||
# Une seule et même structure d'attributs pour les deux kinds —
|
# 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
|
# "ordonnée ou non" se lit directement sur le kind au moment du
|
||||||
# rendu (voir render_document_element._render_list), jamais un
|
# rendu (voir render_document_element._render_list), jamais un
|
||||||
# attribut "ordered" redondant à tenir synchronisé avec le kind.
|
# attribut "ordered" redondant à tenir synchronisé avec le kind.
|
||||||
return {"items": ["Premier élément", "Deuxième élément"]}
|
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":
|
if kind == "row":
|
||||||
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
|
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
|
||||||
if kind == "quiz":
|
if kind == "quiz":
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ attributs par défaut posés à la création de chaque type.
|
|||||||
|
|
||||||
## `CONTENT_KINDS: tuple[str, ...]`
|
## `CONTENT_KINDS: tuple[str, ...]`
|
||||||
`("titre", "paragraphe", "image", "bouton", "liste_puces",
|
`("titre", "paragraphe", "image", "bouton", "liste_puces",
|
||||||
"liste_numerotee")` — éléments du flux, peuvent être top-niveau ou
|
"liste_numerotee", "badge", "carte")` — éléments du flux, peuvent être
|
||||||
enfants d'une rangée. `"liste_puces"`/`"liste_numerotee"` partagent la
|
top-niveau ou enfants d'une rangée. `"liste_puces"`/`"liste_numerotee"`
|
||||||
même structure d'attributs (`items`) ; c'est le `kind` lui-même qui
|
partagent la même structure d'attributs (`items`) ; c'est le `kind`
|
||||||
décide `<ul>` ou `<ol>` au rendu (voir
|
lui-même qui décide `<ul>` ou `<ol>` au rendu (voir
|
||||||
`document_engine/rendering/render_document_element.py::_render_list`),
|
`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, ...]`
|
## `MINIGAME_KINDS: tuple[str, ...]`
|
||||||
`("quiz", "association", "memory", "mots", "scenario", "zones")` —
|
`("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`).
|
`document_engine/elements/add_document_element.py`).
|
||||||
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` :
|
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` :
|
||||||
texte (`content/style` + `bold/italic/underline/align/color`),
|
texte (`content/style` + `bold/italic/underline/align/color`),
|
||||||
image (`src/alt`), bouton (`label/target`), liste à puces/numérotée
|
image (`src/alt/svg_markup` — `svg_markup` prend le pas sur `src` au
|
||||||
(`items`, une liste de chaînes), rangée (`gap/align/justify`),
|
rendu, voir rendering.md), bouton (`label/target/
|
||||||
quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), association
|
attachment_stored_name/attachment_filename` — la pièce jointe est
|
||||||
(`DEFAULT_ASSOCIATION_CONFIG`, voir `association_config.py`), memory
|
indépendante de `target`, réservé à la navigation), liste à
|
||||||
(`DEFAULT_MEMORY_CONFIG`, voir `memory_config.py`), autre mini-jeu
|
puces/numérotée (`items`, une liste de chaînes), badge (`content`),
|
||||||
(`theme_color`), ou `{}` pour un `kind` inconnu.
|
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.
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
## `quiz_config.py` — modèle de données du mini-jeu Quiz
|
## `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:
|
def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||||
a = el["attributes"]
|
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", "")))
|
src = html_lib.escape(str(a.get("src", "")))
|
||||||
alt = html_lib.escape(str(a.get("alt", "")))
|
alt = html_lib.escape(str(a.get("alt", "")))
|
||||||
if not src:
|
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")))
|
label = html_lib.escape(str(a.get("label", "Bouton")))
|
||||||
target = html_lib.escape(str(a.get("target", "")))
|
target = html_lib.escape(str(a.get("target", "")))
|
||||||
target_attr = f' data-target="{target}"' if target else ""
|
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 (
|
return (
|
||||||
f'<button type="button" class="docButton" data-element-id="{el["id"]}" data-kind="bouton"{target_attr}>'
|
f'<button type="button" class="docButton" data-element-id="{el["id"]}" '
|
||||||
f"{label}</button>"
|
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,
|
"bouton": _render_button,
|
||||||
"liste_puces": _render_list,
|
"liste_puces": _render_list,
|
||||||
"liste_numerotee": _render_list,
|
"liste_numerotee": _render_list,
|
||||||
|
"badge": _render_badge,
|
||||||
|
"carte": _render_carte,
|
||||||
"quiz": _render_quiz,
|
"quiz": _render_quiz,
|
||||||
"association": _render_association,
|
"association": _render_association,
|
||||||
"memory": _render_memory,
|
"memory": _render_memory,
|
||||||
|
|||||||
@@ -30,8 +30,25 @@ regroupement à chaque appel.
|
|||||||
récursivement.
|
récursivement.
|
||||||
- **Texte** (`titre`/`paragraphe`) : `<div>` stylé selon `style` (préréglage
|
- **Texte** (`titre`/`paragraphe`) : `<div>` stylé selon `style` (préréglage
|
||||||
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
|
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
|
||||||
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide.
|
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide — OU, si
|
||||||
- **Bouton** : `<button>` avec son `label` et un `data-target` optionnel.
|
`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`) :
|
- **Liste à puces/numérotée** (`liste_puces`/`liste_numerotee`) :
|
||||||
`<ul>` ou `<ol>` selon le `kind` (fonction privée `_render_list`,
|
`<ul>` ou `<ol>` selon le `kind` (fonction privée `_render_list`,
|
||||||
partagée par les deux) — un `<li>` par entrée de `attributes["items"]`.
|
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`) —
|
du type (voir `document_engine/labels/element_kind_labels.py`) —
|
||||||
emplacement réservé, formulaire de contenu dédié hors périmètre de
|
emplacement réservé, formulaire de contenu dédié hors périmètre de
|
||||||
cette passe.
|
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("&", "&").replace('"', """).replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_text(value: str) -> str:
|
||||||
|
return value.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -8,9 +8,11 @@ from . import ( # noqa: F401 - enregistre les routes definies dans chaque modul
|
|||||||
document_edit,
|
document_edit,
|
||||||
document_element_add,
|
document_element_add,
|
||||||
document_element_delete,
|
document_element_delete,
|
||||||
|
document_element_download_attachment,
|
||||||
document_element_move,
|
document_element_move,
|
||||||
document_element_move_to_page,
|
document_element_move_to_page,
|
||||||
document_element_update,
|
document_element_update,
|
||||||
|
document_element_upload_attachment,
|
||||||
document_new,
|
document_new,
|
||||||
document_page_add,
|
document_page_add,
|
||||||
document_page_delete,
|
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),
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -306,6 +306,8 @@ body.objectEditBody > main.content {
|
|||||||
.docLibIcon--bouton::before { content: "⬚"; }
|
.docLibIcon--bouton::before { content: "⬚"; }
|
||||||
.docLibIcon--liste_puces::before { content: "☰"; }
|
.docLibIcon--liste_puces::before { content: "☰"; }
|
||||||
.docLibIcon--liste_numerotee::before { content: "①"; }
|
.docLibIcon--liste_numerotee::before { content: "①"; }
|
||||||
|
.docLibIcon--badge::before { content: "◉"; }
|
||||||
|
.docLibIcon--carte::before { content: "▭"; }
|
||||||
|
|
||||||
.docLibIcon--titre::before {
|
.docLibIcon--titre::before {
|
||||||
content: "T";
|
content: "T";
|
||||||
|
|||||||
@@ -98,6 +98,16 @@ function forgeDocApiMoveToPage(id, targetPageId) {
|
|||||||
}).then((r) => r.json());
|
}).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() {
|
function forgeDocApiRender() {
|
||||||
const url = `/document/${window.forgeDocState.slug}/render?page_id=${window.forgeDocState.activePageId}`;
|
const url = `/document/${window.forgeDocState.slug}/render?page_id=${window.forgeDocState.activePageId}`;
|
||||||
return fetch(url).then((r) => r.json());
|
return fetch(url).then((r) => r.json());
|
||||||
@@ -778,6 +788,14 @@ function forgeDocBindCanvasInteractions() {
|
|||||||
const elementId = Number.parseInt(dom.dataset.elementId, 10);
|
const elementId = Number.parseInt(dom.dataset.elementId, 10);
|
||||||
dom.addEventListener('click', (e) => {
|
dom.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
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);
|
forgeDocSelectElement(elementId);
|
||||||
});
|
});
|
||||||
dom.setAttribute('draggable', 'true');
|
dom.setAttribute('draggable', 'true');
|
||||||
@@ -1649,24 +1667,78 @@ function forgeDocRenderImageProps(panel, el) {
|
|||||||
${forgeDocPropsHeader('image')}
|
${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="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="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()}
|
${forgeDocDeleteButtonHtml()}
|
||||||
`;
|
`;
|
||||||
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
|
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
|
||||||
document.getElementById('docImageSrc').addEventListener('change', (e) => patch({ src: e.target.value.trim() }));
|
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('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) {
|
function forgeDocRenderButtonProps(panel, el) {
|
||||||
const a = el.attributes;
|
const a = el.attributes;
|
||||||
|
const attachmentStatus = a.attachment_filename
|
||||||
|
? `Fichier joint : ${forgeDocEscapeHtml(a.attachment_filename)}`
|
||||||
|
: 'Aucun fichier joint';
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
${forgeDocPropsHeader('bouton')}
|
${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="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"><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()}
|
${forgeDocDeleteButtonHtml()}
|
||||||
`;
|
`;
|
||||||
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
|
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
|
||||||
document.getElementById('docButtonLabel').addEventListener('change', (e) => patch({ label: e.target.value }));
|
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('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) {
|
function forgeDocRenderListItemHtml(item, index) {
|
||||||
@@ -2777,6 +2849,8 @@ function forgeDocRenderProps(el) {
|
|||||||
else if (el.kind === 'image') forgeDocRenderImageProps(panel, el);
|
else if (el.kind === 'image') forgeDocRenderImageProps(panel, el);
|
||||||
else if (el.kind === 'bouton') forgeDocRenderButtonProps(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 === '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 === 'row') forgeDocRenderRowProps(panel, el);
|
||||||
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
|
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
|
||||||
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
|
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
|
||||||
|
|||||||
@@ -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))
|
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>'
|
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 "<script>" 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
|
||||||
|
|||||||
@@ -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
|
("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."""
|
routes d'élément ci-dessous qui exigent désormais un page_id explicite."""
|
||||||
|
|
||||||
|
import io
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask.testing import FlaskClient
|
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
|
assert client.post(f"/document/{victim_slug}/delete").status_code == 403
|
||||||
finally:
|
finally:
|
||||||
db.delete_support(victim_slug)
|
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("") == ""
|
||||||
Reference in New Issue
Block a user