From 7ebc9b143f55a4f8aa007e64dd72fd92485f3a73 Mon Sep 17 00:00:00 2001 From: william Date: Fri, 4 Sep 2026 22:52:05 +0200 Subject: [PATCH] Corrige une faille d'isolation entre comptes et retire l'email des chemins de projet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le rôle "admin" contournait entièrement l'isolation par projet (core/auth_guard.py) : il pouvait ouvrir/modifier/supprimer le jeu de n'importe quel autre compte en connaissant son slug, et la page d'accueil listait sans filtrage tous les projets de tous les comptes. - La propriété d'un projet se vérifie désormais sur le segment "propriétaire" du slug (id du compte), pour tous les rôles y compris admin — un slug "à plat" (sans compte associé) reste réservé à l'admin, comportement historique conservé pour ce cas précis. - routes/games/index.py ne liste plus que les projets du compte connecté. - Le dossier propriétaire d'un projet est maintenant l'id numérique du compte, plus jamais son email slugifié (visible en clair dans chaque URL auparavant) — script de migration fourni et déjà exécuté sur les données existantes. - Changer d'email ne renomme plus aucun dossier (n'en dépend plus). - Deux nouveaux tests de régression, fixtures corrigées en conséquence. - README réécrit pour refléter l'état actuel du produit (jeu 2D uniquement, plus de traces de l'ancien éditeur "document"). --- README.md | 815 +++----------------- core/auth_guard.py | 54 +- db/games/create_game.py | 5 +- db/games/list_games.py | 36 +- db/games/move_game.py | 23 +- routes/auth/profile.py | 12 +- routes/games/games_new.py | 8 +- routes/games/index.py | 7 +- routes/onboarding/onboarding_new.py | 4 +- scripts/backfill_onboarding_type.py | 41 - scripts/migrate_owner_folders_to_user_id.py | 103 +++ tests/conftest.py | 22 +- tests/test_auth.py | 52 +- tests/test_db_layer.py | 11 +- tests/test_onboarding.py | 3 +- tests/test_profile.py | 19 +- 16 files changed, 382 insertions(+), 833 deletions(-) delete mode 100644 scripts/backfill_onboarding_type.py create mode 100644 scripts/migrate_owner_folders_to_user_id.py diff --git a/README.md b/README.md index c8757852..113f9b27 100644 --- a/README.md +++ b/README.md @@ -1,748 +1,143 @@ -# Forge Engine — prototype (lot de fonctionnalités n°1) +# Forge Engine -Outil no-code : créer des jeux, définir leurs objets de données (comme des -tables de base de données, avec des champs typés et des relations entre -eux), et remplir ces données via des formulaires générés automatiquement. +Éditeur no-code de serious games 2D pour la formation professionnelle, +dans le navigateur. Créé pour un public **non technique** (formateurs, +RH) : aucune ligne de code, aucun graphe de logique générique — un +assistant pas à pas pour chaque mécanique de jeu. -Python + SQLite + Flask. Chaque jeu créé a sa propre base de données réelle -(fichier `.db`), pas de simulation : définir un objet exécute un vrai -`CREATE TABLE`, remplir le formulaire exécute un vrai `INSERT`. +> Voir `.claude/Forge_Engine_Cadrage.pdf` (non versionné, document +> business interne) pour le positionnement produit complet et l'analyse +> de marché qui a guidé les choix ci-dessous. -## Lancer (pour tester maintenant, en ligne de commande) +## Lancer le projet ```bash -cd forge-engine pip install -r requirements.txt python3 app.py ``` Le navigateur s'ouvre automatiquement sur http://127.0.0.1:5050. -*(Ceci est la version de développement. Comme convenu, une version finale -empaquetée — double-clic, sans terminal ni installation — sera produite une -fois que toutes les fonctionnalités prévues auront été ajoutées.)* +## Ce que permet Forge Engine aujourd'hui -## Ce qui est construit dans ce lot +Chaque jeu créé est un **RPG 2D** : une ou plusieurs scènes (des décors à +taille fixe), sur lesquelles on pose des personnages, un fond, et des +widgets d'interface — tout au glisser-déposer, sans jamais toucher à du +HTML/CSS/JS. -1. **Créer un jeu** : bouton sur la page d'accueil → crée - `projects//index.html` + `index.css` + `index.js` (reliés - entre eux), et `projects//game.db` (base SQLite dédiée). -2. **Définir un objet** (comme une table de base de données) : nom + liste - de champs typés (texte, texte long, nombre entier, nombre décimal, - oui/non, date, relation vers un autre objet déjà défini dans ce jeu). - Chaque objet défini devient une vraie table SQL (`obj_`), avec les - bonnes colonnes et, pour les relations, une vraie clé étrangère. -3. **Formulaire généré automatiquement** : une fois un objet défini, le - moteur lit sa définition et construit lui-même le formulaire de saisie - adapté (bon type de champ HTML selon le type choisi, menu déroulant - peuplé avec les entrées existantes pour les relations). Valider le - formulaire enregistre une vraie ligne dans la table SQL correspondante. -4. **CRUD complet** sur les trois niveaux : - - **Jeu** : renommer, supprimer (dossier + base de données). - - **Objet** (page "✏️ Modifier cet objet") : renommer, ajouter un champ - (vrai `ALTER TABLE ... ADD COLUMN`), retirer un champ (vrai - `ALTER TABLE ... DROP COLUMN`), supprimer l'objet entier (vrai - `DROP TABLE`) — **bloqué** si un autre objet a une relation vers - celui-ci, avec indication de quel(s) objet(s). - - **Donnée** (une ligne) : modifier, supprimer — **bloqué** si une autre - ligne pointe vers elle via une relation, avec indication de combien et - depuis quel objet. +- **Personnages** : bibliothèque de sprites prêts à l'emploi (personnages + Kenney CC0 + animaux CraftPix pour les comptes admin), animés + automatiquement (idle, marche...). Un rôle — **Joueur** (déplacement au + clavier, suivi par la caméra), **PNJ** ou **Ennemi** — se choisit + explicitement par personnage ; un personnage fraîchement posé est + toujours "PNJ" par défaut. +- **Collision** (onglet dédié de l'éditeur de scène) : pour chaque objet + de la scène (jamais le joueur lui-même), un assistant "+ Action" + construit une règle "déclencheur → action" pièce par pièce (jamais un + formulaire à plusieurs champs) — à la collision ou dans un périmètre, + déclencher une quête, ou une interaction au clavier ("Appuie sur ...") + qui elle-même déclenche une quête. Toute la liste se met à jour en + AJAX (ajout/suppression d'objet, changement de rôle/nom), sans jamais + recharger la page. +- **Quêtes & dialogues** : chaque quête a un titre, un objectif, une + récompense, un statut et un résultat ; son dialogue se rédige + séparément (3 colonnes — Nouvelle / En cours / Terminée — pour faire + varier ce que dit un PNJ selon l'avancement). +- **Widgets d'interface** : Boîte de dialogue, Boîte à quiz, Score — posés + comme n'importe quel objet de scène, gérés automatiquement par le + moteur de jeu (jamais de logique à câbler), avec leur propre style + (police, couleurs) réglable une fois posés. +- **Export SCORM 1.2** natif (`routes/publish/export_scorm.py`) : un + paquet .zip autonome (imsmanifest.xml + runtime hors-ligne), prêt à + déposer dans un LMS (Moodle, 360Learning...) — score et statut de la + partie remontent automatiquement. -5. **Écrans de jeu** (`🖥️ Écrans` depuis le tableau de bord d'un jeu) — - éditeur façon Elementor/Bricks Builder, pensé pour quelqu'un qui ne - connaît ni le HTML ni le CSS : - - **3 panneaux** : à gauche, une grille d'icônes pour ajouter un élément - — aucun nom de balise HTML visible nulle part. Au centre, l'écran en - très grand (repères Portrait / Paysage / Carré, juste visuels — l'écran - réel du joueur reste toujours responsive). À droite, les propriétés de - l'élément sélectionné, groupées par thème et repliables comme un - accordéon (Position & taille, Contenu, Texte, Disposition, Espacement, - Bordure, Actions...) — un seul groupe utile ouvert par défaut, les - autres se déplient d'un clic sur leur titre, pour garder le panneau - lisible même avec beaucoup de réglages. - - **20 types d'éléments** : Texte, Titre, Bouton, Lien, Image (URL ou - fichier envoyé depuis ton ordinateur — l'envoi enregistre aussitôt - l'image, sans clic supplémentaire à faire pour la voir apparaître), - Vidéo (idem), Champ de - formulaire — texte / email / mot de passe, Case à cocher, Bouton radio, - Zone de texte, Liste déroulante, Groupe de champs, Liste à puces, Liste - numérotée, Tableau, Conteneur / décor, Séparateur, Élément de jeu du - catalogue, et le **Répéteur de données** (voir plus bas). - - **Un réglage = un seul champ adapté** : case à cocher pour "gras" ou - "obligatoire", curseur pour une taille/un arrondi/un espacement, pastille - de couleur, boutons ⬅️/⬛/➡️/☰ pour l'alignement — jamais de nom de - propriété CSS à taper. Chaque élément dispose maintenant, en plus de ses - réglages propres, d'un socle commun : **visibilité** (visible / masqué / - invisible-mais-garde-sa-place), **espacement** (marge intérieure et - extérieure), **bordure** (épaisseur, couleur, style), et pour les - conteneurs/listes/tableaux, une **disposition interne** façon flexbox - (empilement libre / en ligne / en colonne, avec l'espacement entre - éléments). Les textes ont en plus gras, italique, souligné, barré et un - choix de police (polices système + quelques Google Fonts). - - **Position & taille visibles et modifiables** : 4 champs (X, Y, - Largeur, Hauteur, en %) dans le panneau de droite, synchronisés avec le - glisser-déposer sur l'écran. - - **Glisser-déposer et redimensionnement à la souris**, exactement comme - avant : on peut vraiment attraper un élément et le faire glisser sur - l'écran (un simple clic sans glisser le sélectionne juste). Sans jamais - toucher à un fichier HTML/CSS : tout est stocké en base et généré à la - volée. - - **ID unique par élément** : chaque élément affiche son identifiant - (`elt-`) dans ses propriétés — attribué automatiquement, garanti - unique dans le jeu. - - **Enchaînement des écrans** : liste ordonnée (premier écran, deuxième - écran...), réorganisable (↑ / ↓). - - **🔀 Logique de la scène (éditeur de flow à nœuds)** : la logique d'un - écran (ce qui se passe au clic ou à la soumission d'un formulaire) ne - se règle plus dans les propriétés de chaque élément, mais dans un - panneau dédié, rétractable, en bas de l'éditeur d'écran — un vrai - canevas où l'on pose des **nœuds** et où on les relie par des fils, - dans l'esprit de Salesforce Flow ou d'Unity Visual Scripting, mais - appliqué directement aux éléments et aux objets de données déjà - définis dans le jeu : - - **Déclencheur** (contour bleu) : "Au clic sur..." ou "À la - soumission de..." un élément de l'écran — c'est le point de départ - du graphe. - - **Condition** (contour orange) : compare un champ d'une ligne d'un - objet à une valeur (égal, différent, supérieur, inférieur...), et - propose deux fils de sortie, **Vrai** et **Faux**, à relier chacun à - la suite du graphe qui doit s'exécuter dans ce cas. - - **Action** (contour vert) : les mêmes effets qu'avant — changer - d'écran, modifier un élément (visibilité, couleurs, taille), ou - modifier une donnée d'un objet (texte, nombre, Vrai/Faux, bascule, - +/- un montant). - Pour relier deux nœuds : clique le point de sortie du premier (côté - droit), puis le point d'entrée du second (côté gauche) — un fil - apparaît. Reclique un fil pour le supprimer. Chaque nœud peut être - glissé sur le canevas pour organiser le graphe, et supprimé avec sa - petite croix. Une action "modifier une donnée" met à jour la vraie - base de données du jeu, et tout Répéteur de données affichant cet - objet à l'écran se met aussitôt à jour tout seul, sans recharger la - page. - **Plusieurs actions pour un même déclencheur** : le point de sortie - d'un nœud (Déclencheur, ou une branche Vrai/Faux d'une Condition) peut - être relié à PLUSIEURS nœuds suivants — au clic, tous s'exécutent (pas - seulement le premier relié). +### Onboarding - **Effet "va-et-vient" (état à deux positions)** : pour une action - "Modifier un élément" sur une couleur, une largeur ou une hauteur, une - case à cocher "Va-et-vient" fait apparaître une deuxième valeur — le - 1er clic applique la 2de valeur, le 2e clic revient à la 1re, et ainsi - de suite indéfiniment (par exemple : couleur de fond blanche au - départ, bleue au clic, blanche au reclic...). Cet état est mémorisé - uniquement dans la page du joueur (pas en base) : il repart de zéro si - l'écran est rechargé. +Un seul parcours de création à l'inscription : **RPG**. La carte de choix +(`templates/onboarding/onboarding_new.html`) se retourne pour présenter +l'argumentaire produit. - **Limites connues de cette première version** : un nœud ne peut pas - être modifié une fois créé (il faut le supprimer et en recréer un avec - les bons réglages) ; une condition compare toujours une ligne précise - d'un objet choisie à la création du nœud (pas encore "la ligne sur - laquelle le joueur vient de cliquer" dans un Répéteur) ; il n'y a pas - encore de nœud "ET / OU" pour combiner plusieurs conditions avant un - branchement ; l'effet "va-et-vient" ne survit pas à un rechargement de - page (pour un état qui doit être sauvegardé, utiliser plutôt une - Condition sur un champ Oui/Non d'un objet, combinée à une action - "Modifier une donnée" en "Basculer Vrai/Faux"). - - **Mise en page de l'éditeur d'écran, revue** : la page ne défile plus - jamais elle-même — elle occupe exactement la hauteur de la fenêtre. - Les trois panneaux (ajout d'éléments, écran, propriétés) s'arrêtent - tous au même niveau en bas, et défilent chacun pour soi si leur - contenu dépasse (la liste d'éléments, l'écran lui-même en orientation - portrait sur un grand écran, etc.). La grille "Ajouter un élément" est - désormais repliable (clique son titre) pour libérer de la place. Le - panneau "🔀 Logique de la scène", une fois ouvert, partage la hauteur - de la fenêtre avec le reste au lieu de s'afficher par-dessus : une - poignée juste au-dessus de son titre permet de régler sa hauteur à la - souris (glisser vers le haut pour l'agrandir, vers le bas pour le - réduire). - - **Répéteur de données** : relie un élément à un objet défini dans ce jeu - (voir "Définir un objet" plus haut) et affiche automatiquement une ligne - par donnée existante, à partir d'un modèle de texte avec des - `{{nom_du_champ}}` — par exemple, un objet "Niveau" avec des champs - "nom" et "description" peut s'afficher comme `{{nom}} — {{description}}` - répété pour chaque niveau existant, sans rien recopier à la main. La - disposition interne (empilement/ligne/colonne + espacement) s'applique - à la liste générée. Réglage "Modèle de ligne" : au lieu du modèle de - texte, on peut choisir un élément de jeu du catalogue — chaque ligne - est alors affichée avec sa mise en forme complète (voir "Éléments de - jeu" ci-dessous) plutôt qu'en texte brut. - - **Imbrication réelle d'éléments** : un Conteneur / décor, un Répéteur de - données ou un Groupe de champs peut désormais contenir d'autres - éléments *physiquement* — texte, image, bouton, un autre conteneur... - posés dedans plutôt qu'à côté. Dans le panneau de droite, un élément de - ce type affiche une section "Contenu du conteneur" : la liste de ce - qu'il contient déjà, et la même grille d'icônes que "Ajouter un - élément" pour y placer un nouvel élément. Comme ces éléments deviennent - de vrais enfants dans la page générée, la disposition interne (ligne / - colonne), l'espacement intérieur (padding) et la bordure du parent - s'appliquent réellement à eux — exactement comme le ferait une vraie - mise en page flexbox. Un élément posé à l'intérieur d'un Répéteur de - données reçoit lui aussi les `{{nom_du_champ}}` de la ligne en cours : - il se répète donc avec le reste. L'imbrication peut se faire sur - plusieurs niveaux (un conteneur dans un conteneur, etc.). Un élément - posé à l'intérieur d'un autre garde sa hauteur naturelle (comme sur une - vraie page web) au lieu de forcer 100% de la hauteur du parent — sinon - un deuxième élément ajouté dans le même conteneur se retrouvait poussé - hors de la zone visible et disparaissait. - - **Échelle (zoom)** : un nouveau réglage curseur, disponible sur tous les - éléments (dans "Disposition"), pour agrandir ou réduire un élément sans - toucher à sa largeur/hauteur — utile par exemple pour un effet de - zoom au survol ou une icône plus petite que sa zone cliquable. - - **Disposition interne complète** (conteneurs, répéteurs, listes, - tableaux, groupes de champs) : en plus de ligne/colonne, les variantes - inversées (ordre inversé), l'alignement transversal (étirés / au - début / centrés / à la fin), la répartition dans le sens de la - disposition (au début / au centre / à la fin / espacement égal entre, - autour, ou uniforme) et une case "autoriser le retour à la ligne" — - de quoi retrouver, avec des mots simples, tout ce qu'une vraie - disposition flexbox permet de régler. - - **Taille dans le conteneur** (nouveau réglage "Taille dans le - conteneur", disponible sur tous les éléments) : deux curseurs Largeur / - Hauteur en pixels, à 0 par défaut (comportement automatique — un - élément posé à l'intérieur d'un conteneur/répéteur/groupe de champs - remplit la largeur disponible et s'ajuste en hauteur à son contenu). - Une valeur non nulle impose une taille fixe et n'est plus comprimée par - la disposition flex environnante : c'est ce qui permet de redimensionner - une image (ou n'importe quel autre élément) posée à l'intérieur d'un - conteneur — jusqu'ici impossible. - - **Correction — collision en disposition "ligne"** : dans un conteneur - réglé sur "Alignés côte à côte (ligne)", chaque élément posé à - l'intérieur réclamait par défaut 100% de la largeur du conteneur (comme - dans les autres dispositions) — deux éléments côte à côte se - retrouvaient donc à se disputer toute la largeur, et la disposition - flex les comprimait fortement pour les faire tenir (un des deux, souvent - une image, pouvait finir écrasé à quelques pixels de large, voire - disparaître visuellement). Par défaut, un élément posé dans une - disposition en ligne prend maintenant sa taille naturelle (comme le - ferait un élément Elementor/Webflow), ce qui rend aussi immédiatement - visibles "Espacement égal entre eux / autour / uniforme" : tant que - chaque élément occupait 100% de la largeur, il n'y avait aucun espace - restant à répartir entre eux. - - **Image — ajustement dans son cadre (object-fit)** : nouveau réglage - (Remplir en recadrant / Tout montrer sans déformer / Étirer) qui - contrôle comment l'image se comporte quand sa taille réelle ne - correspond pas exactement à la taille de son cadre — utile dès qu'une - largeur et une hauteur fixes sont données à une image (voir "Taille - dans le conteneur" ci-dessus) pour éviter qu'elle soit déformée. - "Remplir en recadrant" est désormais la valeur par défaut (avant, une - image étirée dans un cadre qui n'avait pas ses proportions était - déformée sans recours). - - **Nommer un élément** : chaque élément peut recevoir un nom (ex: "Bouton - Valider", "Image du héros") dans ses propriétés, pour s'y retrouver - dans les listes et dans le choix d'une cible d'action sans avoir à - deviner lequel est lequel parmi plusieurs éléments du même type. Sans - nom, l'élément garde son libellé de type par défaut (ex: "Image"). - - **Sélection directe d'un élément imbriqué** : plus besoin de repasser - par la liste "Éléments de cet écran" à chaque fois — un clic sur - l'écran sélectionne précisément l'élément touché, même s'il est - physiquement à l'intérieur d'un conteneur, d'un répéteur ou d'un - groupe de champs (le glisser-déposer, lui, continue de déplacer la - boîte de plus haut niveau, comme avant). - - **Éléments de jeu** (`🧩` depuis le tableau de bord) : un catalogue de - conteneurs réutilisables (Personnage, Outil, icône de mail...), chacun - bâti exactement comme un écran — on ouvre "✏️ Modifier le contenu" et on - y imbrique/stylise des éléments avec l'éditeur d'écran normal (c'est en - réalité un écran caché, invisible dans la liste des écrans du jeu et en - mode jouable). On peut optionnellement le lier à un objet, pour utiliser - `{{nom_du_champ}}` dans son contenu. Une fois construit, il a deux - usages : - - **Posé directement sur un écran** (bouton "🧩 Élément de jeu" dans - "+ Ajouter un élément") : tout son contenu est copié en profondeur en - éléments indépendants sur cet écran, modifiables séparément par la - suite — comme avant. Limite de cette version : si l'élément est lié à - un objet, `{{nom_du_champ}}` n'est alors résolu par aucune ligne - précise et reste affiché tel quel ; ce mode convient surtout aux - éléments décoratifs (icônes, outils, décor) sans lien à un objet. - - **Choisi comme "Modèle de ligne" d'un Répéteur de données** (réglage - du Répéteur, à la place du modèle de texte) : pour chaque ligne de - l'objet affiché par le Répéteur, son contenu est réaffiché en direct - (jamais copié en base) avec les `{{nom_du_champ}}` de cette ligne — - c'est ainsi qu'un objet "Mail" avec 5 lignes peut s'afficher comme 5 - cartes stylisées (icône + sujet + expéditeur...) plutôt que comme du - texte brut. Le Répéteur et l'éditeur d'écran n'ont besoin de rien - connaître du contenu de l'élément de jeu utilisé : la mise en forme - vit entièrement dans le catalogue, réutilisable d'un écran (ou d'un - jeu) à l'autre. - Supprimer un élément de jeu du catalogue supprime aussi son contenu - (l'écran caché) ; c'est bloqué tant qu'au moins un exemplaire est posé - directement sur un écran (voir "Suppression protégée" ci-dessous). - - **Mode jouable** (`▶️ Jouer`) : joue réellement l'enchaînement des - écrans et déclenche les actions au clic, dans le navigateur, à partir - des données enregistrées (y compris les Répéteurs, lus en direct) — - aucun fichier généré. - - Suppression protégée : un écran ciblé par une action, ou un élément de - jeu posé sur un écran, ne peuvent pas être supprimés tant que la - référence existe. - - Limite volontaire de cette version : le Tableau et la Liste déroulante - se remplissent encore via un champ de texte (une ligne par entrée), pas - en y glissant d'autres éléments un par un — l'imbrication réelle - (Conteneur / Répéteur de données / Groupe de champs) est, elle, - disponible (voir "Imbrication réelle d'éléments" ci-dessus). - - **Corrections** : le bouton "Définir un nouvel objet" du tableau de bord - a désormais le même style de carte que les autres raccourcis (Écrans, - Éléments de jeu, Jouer) ; une erreur `database is locked` pouvait - survenir en supprimant un élément (deux requêtes SQLite concurrentes - sur le serveur de développement) — corrigé en activant le mode WAL et - un délai d'attente sur les verrous de la base. - - **Confusion "Ajouter un élément" vs "Ajouter DANS ce conteneur"** : - les deux grilles se ressemblaient à l'identique (mêmes icônes, même - mise en page), alors qu'elles ne posent pas l'élément au même endroit - — celle de gauche le pose directement SUR L'ÉCRAN, celle de droite (qui - n'apparaît que si un conteneur/répéteur/groupe de champs est - sélectionné) le pose À L'INTÉRIEUR de cet élément. Cliquer par erreur - sur celle de gauche pendant qu'un conteneur est sélectionné posait un - nouvel élément par-dessus tout le reste (toujours à la même position - par défaut), qui pouvait donner l'impression trompeuse qu'un "élément - indésirable" venait d'apparaître et d'en cacher un autre. Corrigé par - trois changements : la grille de gauche s'appelle maintenant "Ajouter - un élément **sur l'écran**" (sans ambiguïté) ; celle de droite - s'appelle "📥 Ajouter **DANS ce conteneur**" avec un habillage vert - distinct ; et un avertissement apparaît dans le panneau de gauche - quand un conteneur est sélectionné, pour rappeler laquelle des deux - grilles utiliser. Un nouvel élément posé directement sur l'écran est - aussi désormais légèrement décalé par rapport au précédent (au lieu de - toujours atterrir exactement à la même position), pour limiter les - recouvrements accidentels même en cas d'usage normal. - - **Une couleur de fond apparaissait toute seule en changeant la - disposition/l'alignement d'un conteneur, et un élément pouvait - "disparaître"** : le panneau de propriétés d'un élément envoie TOUS ses - réglages dans un seul formulaire — donc enregistrer un changement de - disposition envoyait aussi, par exemple, le champ de couleur de fond, - même non modifié. Or un `` affiche toujours une - valeur (la couleur par défaut du réglage, purement indicative, quand - rien n'a encore été choisi) — cette valeur d'aperçu était donc écrite - "en dur" dans le style à chaque enregistrement, quel que soit le champ - réellement modifié, ce qui faisait apparaître un fond qui n'avait - jamais été demandé (et, en s'accumulant avec d'autres réglages par - défaut, pouvait rendre un élément visuellement méconnaissable). Corrigé - à la racine : un réglage n'est désormais écrit dans le style/les - attributs que s'il a été explicitement modifié — sa valeur par défaut - reste un simple aperçu tant qu'on n'y touche pas. Les réglages dont la - valeur par défaut a un effet visuel réellement voulu dès la création - (ex. "Ajustement dans son cadre" = cover pour une image, afin qu'elle - se recadre proprement une fois redimensionnée plutôt que d'être - étirée) sont désormais fixés directement à la pose de l'élément, pas - au premier enregistrement du panneau — cela ne dépend donc plus de - l'ordre dans lequel les réglages sont modifiés. +### Ce qui a été délibérément retiré -### Sur l'ordre de création avec des relations +Une première version du moteur exposait aussi un type d'écran "document" +— un éditeur générique façon no-code (blocs de logique en nœuds, timeline +d'animation, définitions d'objets/champs/relations façon base de +données, templates réutilisables). Cette complexité ne correspond à +aucun besoin du public cible (formateurs non techniques) et a été +supprimée pour recentrer entièrement le produit sur le jeu 2D — voir +l'historique Git pour le détail de ce retrait. -Une relation pointe toujours vers un objet **déjà défini**. Donc si tu veux -un objet "Niveau" avec un champ relation vers "Parcours", il faut créer -l'objet **"Parcours" en premier** (même sans aucune donnée dedans), puis -créer "Niveau" et choisir "Parcours" comme objet lié pour son champ -relation. C'est l'inverse de l'exemple donné : c'est l'objet **visé** par -la relation qui doit exister avant l'objet qui la porte — pas l'objet qui -la porte avant l'objet visé. +## Ce qui manque encore pour une mise sur le marché (voir le cadrage) -### Navigation sans rechargement de page (zéro rechargement) +Sur les 6 fonctionnalités listées comme bloquantes (condition d'achat) par +le document de cadrage, seul l'**export SCORM** est construit. Restent à +faire : support **xAPI**, intégration **LTI 1.3**, **hébergement UE + +RGPD** (DPA), accessibilité **RGAA/WCAG 2.2 AA**. L'export HTML5 +responsive existe déjà via le mode Jouer. -Toute la navigation dans l'application (pas seulement l'éditeur d'écran) -passe maintenant par `static/pjax.js`, une petite couche "PJAX" (dans -l'esprit de Turbo/Hotwire) : chaque clic sur un lien interne et chaque -soumission de formulaire interne est intercepté, la page suivante est -récupérée en arrière-plan (`fetch`), et seuls le ``, l'en-tête (fil -d'Ariane) et le contenu principal (`<main>`) sont remplacés — au lieu de -laisser le navigateur recharger toute la page. L'URL affichée et le bouton -"précédent" du navigateur restent corrects grâce à -`history.pushState`/`popstate`. - -Pourquoi PJAX plutôt qu'une réécriture complète en SPA (React ou autre) : -le moteur reste 100% rendu côté serveur en Jinja2, ce qui est le bon choix -pour un outil interne piloté par une base SQLite par jeu — une SPA aurait -demandé de dupliquer toute la logique d'affichage côté client (une API -JSON, un routeur, un state management) pour un gain quasi nul ici, alors -que PJAX obtient le même résultat perçu ("zéro rechargement", historique -correct, formulaires qui marchent) en ne touchant qu'à une seule couche -fine, sans toucher aux routes Flask ni aux templates existants. - -Points d'attention si tu ajoutes une page avec un script propre à cette -page (dans `{% block content %}` ou via `extra_head`) : - -- Ce script est réinjecté et **réexécuté** à chaque navigation vers cette - page (y compris quand on y revient plusieurs fois) : évite `let`/`const` - au premier niveau du script (une redéclaration lèverait une erreur) — une - déclaration de fonction ou une variable `var` est sans risque. -- Pour désactiver PJAX sur un lien ou un formulaire précis (rare), ajoute - l'attribut `data-no-pjax`. Les liens `target="_blank"` (ex. "▶️ Jouer") - et les liens de téléchargement (`download`) sont déjà ignorés - automatiquement. -- Les formulaires protégés par un `confirm()` JavaScript (ex. suppression) - continuent de fonctionner normalement : si l'utilisateur annule la - boîte de dialogue, PJAX n'intercepte rien et rien ne se passe. - -### Éditeur d'écran : tout est automatique (déplacer, redimensionner, régler) - -L'éditeur d'écran (`templates/screen_edit.html`) va plus loin que le PJAX -général ci-dessus, parce qu'il s'agit d'un usage bien plus intensif : -déplacer/redimensionner un élément à la souris et régler ses propriétés -sont des actions qu'on répète en continu pendant qu'on construit un écran, -pas des navigations occasionnelles. Deux comportements dédiés, indépendants -de `pjax.js` : - -- **Glisser-déposer et redimensionnement** ne provoquent plus aucune - navigation. Tant que l'élément déplacé/redimensionné reste celui déjà - sélectionné, rien n'est rechargé : sa nouvelle position/taille est - envoyée en arrière-plan (`fetch`) et les champs "X/Y/Largeur/Hauteur" du - panneau de droite sont mis à jour directement en JavaScript. Cliquer - (sans glisser) sur un AUTRE élément pour le sélectionner ne recharge pas - la page non plus : seul le bloc central de l'éditeur (`#builder3` — la - liste d'éléments, le canevas et le panneau de propriétés) est regénéré - via une requête en arrière-plan et réinjecté, sans toucher au reste de la - page (l'éditeur de logique en bas, par exemple, garde son état). -- **Le panneau de propriétés s'enregistre entièrement tout seul** — il n'y - a plus de bouton "Enregistrer". Chaque réglage se sauvegarde dès qu'on le - change : immédiatement pour une case à cocher, une couleur ou une liste - déroulante ; après une courte pause (500 ms) pour un champ texte ou un - slider qu'on est en train de glisser, pour éviter d'envoyer une requête à - chaque caractère tapé. Un indicateur ("Enregistrement..." / "Enregistré - automatiquement ✓") remplace l'ancien bouton. Après chaque sauvegarde, - seul le canevas (`#canvas`) est rafraîchi pour refléter le changement - visuellement — le formulaire de propriétés lui-même n'est jamais - retouché, pour ne jamais faire perdre le focus ou la position du curseur - pendant qu'on tape. - -Point d'attention si tu ajoutes un nouveau contrôle ou une nouvelle zone -dans ce panneau : toute logique branchée avec `addEventListener` (pas un -attribut `onclick`/`onchange` inline) doit être (re)branchée dans -`initBuilderPanel()` — cette fonction est rappelée après chaque changement -de sélection, puisque `#builder3` est entièrement regénéré à ce moment-là. - -### Cliquer une ligne de Répéteur (déclencheur + action "Ouvrir la ligne cliquée") - -Un Répéteur affiche une ligne par entrée d'un objet de données (ex : la -liste des mails d'une boîte de réception), mais toutes ses lignes -partagent le même modèle d'éléments — il n'existait donc aucun moyen de -dire "au clic sur CETTE ligne précise, ouvre CE mail précis" : un -déclencheur ne pouvait viser qu'un élément fixe posé une fois pour toutes -sur l'écran. - -Ça fonctionne maintenant ainsi : - -- Chaque ligne rendue par un Répéteur porte un attribut `data-row-id` (le - vrai id de la ligne de données, voir `screens/rendering/render_repeater.py`) - — à ne pas confondre avec `data-element-id`, qui reste l'id du MODÈLE de - ligne et se répète à l'identique sur chaque ligne. -- Dans l'éditeur de logique, choisir le Répéteur lui-même comme "Élément" - d'un nœud Déclencheur ("Au clic") fait réagir n'importe laquelle de ses - lignes au clic (le clic remonte naturellement jusqu'au conteneur du - Répéteur). -- La nouvelle action **"Ouvrir la ligne de Répéteur cliquée"** retient - quelle ligne a réellement été cliquée (`window.lastClickedRowId` / - `window.lastClickedDefinitionId`, capturés au clic dans `bindClicks()` - de `templates/play.html`), affiche l'écran de détail choisi, puis - résout tous les `{{champ}}` restés tels quels sur cet écran - (`applyOpenRowBindings()`) avec les valeurs de CETTE ligne — alors que - jusqu'ici `{{champ}}` ne se résolvait qu'à l'intérieur d'un Répéteur - (limitation encore documentée dans les tests pour un élément posé - directement depuis le catalogue). -- Limite connue : si le déclencheur est posé sur un élément à l'INTÉRIEUR - du modèle de ligne (ex: juste le titre) plutôt que sur le Répéteur - lui-même, `data-definition-id` n'est pas disponible sur cet élément et - l'action ne saura pas résoudre la donnée — pose toujours le déclencheur - sur le Répéteur. - -### Un seul écran qui s'adapte à la partie (filtre de Répéteur + déclencheur "affichage") - -Certains jeux (ex: une boîte mail avec plusieurs niveaux) n'ont besoin que -d'un SEUL écran de jeu, dont le contenu doit changer selon l'état de la -partie (niveau atteint, outils débloqués...) plutôt que d'un écran par -niveau à maintenir en synchronisation. Deux briques rendent ça possible : - -- **Filtre sur un Répéteur** (nouveaux réglages "Filtre" dans ses - propriétés) : ne garde que les lignes dont un champ correspond à une - valeur — fixe (ex. `3`) OU une référence `{{NomDeLObjet.nom_du_champ}}` - qui va lire la valeur ACTUELLE de ce champ sur la ligne la plus récente - de cet autre objet (voir `screens/rendering/filter_repeater_rows.py`). - Convention : un objet utilisé comme "état de partie" (ex. un objet - "Partie" avec un champ "niveau_courant") ne garde qu'UNE seule ligne, - mise à jour en place par des actions "Modifier une donnée" plutôt que - d'en créer une nouvelle à chaque fois — la référence prend toujours la - ligne la plus récente. Le filtre est réévalué à chaque régénération du - HTML du jeu (chargement de `/play`, et rafraîchissement après toute - action "Modifier une donnée"), donc automatiquement à jour. -- **Déclencheur "À l'affichage de l'écran"** (nouvel événement, en plus de - "Au clic" et "À la soumission") : contrairement aux autres déclencheurs, - il ne cible pas un élément précis mais l'ÉCRAN ENTIER, et s'exécute - automatiquement — au premier affichage, à chaque retour sur cet écran, ET - après toute donnée modifiée pendant qu'on y est déjà (voir - `runScreenShowTriggers()` dans `templates/play.html`, appelée depuis - `showScreen()` et `refreshRuntimeData()`). Combiné à un nœud Condition et - une action "Modifier un élément → Visibilité", ça permet de cacher/montrer - un élément selon l'état de la partie SANS qu'un clic soit nécessaire pour - le réévaluer — la limite qui empêchait un outil de réapparaître "débloqué" - simplement en revenant sur l'écran. À éviter avec le mode "va-et-vient" - d'une action liée : il serait réévalué à chaque affichage et basculerait - de façon imprévisible plutôt que de rester stable. - -Point d'attention : le filtre de Répéteur ne fait pas de vraie jointure — -la référence `{{Objet.champ}}` prend la ligne la plus récente de l'objet -visé, pas "la ligne liée à la ligne courante d'un autre Répéteur" ; pour un -état de partie à une seule ligne (le cas d'usage visé), ça suffit. - -## Jauge liée à une donnée, bornage automatique, onglets, conditions combinées - -Quatre briques qui se combinent bien avec le déclencheur "À l'affichage de -l'écran" ci-dessus, pour construire un écran qui réagit à l'état de la -partie sans code : - -- **Widget "Jauge (liée à une donnée)"** (nouveau widget, icône 📊) : - affiche une barre dont la largeur ET la couleur suivent en direct un - champ numérique d'un objet — pas besoin d'ajouter la moindre action, la - jauge se relit automatiquement à chaque rafraîchissement des données - (chargement de `/play`, après toute action "Modifier une donnée"). Ses - réglages : Objet, Champ, Min/Max (bornes d'affichage — la barre est visuellement - clampée à 0%/100% même si la vraie valeur dépasse), Couleur basse/haute - (dégradé interpolé linéairement selon la position dans la plage), et une - case "Afficher la valeur" pour incruster le nombre au centre de la barre. - Lit toujours la ligne la PLUS RÉCENTE de l'objet visé (même convention - "état de partie à une seule ligne" que le filtre de Répéteur). Voir - `screens/rendering/render_jauge.py`. -- **Bornage automatique des champs numériques** : un champ "Nombre entier" - ou "Nombre décimal" peut désormais avoir un Min et/ou un Max réglés à la - création de l'objet (ou ajoutés après coup en édition). Toute action - "Modifier une donnée" (Augmenter de, Diminuer de, Définir à...) qui - ferait sortir la valeur de cette plage est automatiquement RAMENÉE à la - borne dépassée, plutôt que de continuer à s'accumuler sans limite — plus - besoin de poser une Condition séparée juste pour empêcher une jauge de - réputation de dépasser 100 ou de passer sous 0. Voir - `_clamp_to_field_bounds()` dans `screens/data_actions/apply_data_action.py`. -- **Panneau à onglets / visibilité mutuellement exclusive** : nouveau type - d'action "Afficher cet élément, masquer tous ses frères (onglets / - exclusif)" (`activer_onglet`). Au lieu de poser une action "Modifier - élément → Visibilité" par bouton à masquer (N-1 actions pour N onglets), - UNE SEULE action suffit : au clic, elle montre l'élément ciblé et cache - automatiquement tous les autres éléments qui partagent le même parent - (même conteneur) dans l'écran — exactement le comportement d'un jeu - d'onglets ou d'un menu à options exclusives. Voir la fonction - `runActionNode()` (branche `'activer_onglet'`) dans `templates/play.html`. -- **Conditions combinées (ET / OU)** : un nœud Condition peut désormais - tester PLUSIEURS champs à la fois dans un seul nœud, au lieu d'enchaîner - plusieurs nœuds Condition pour un ET, ou de dupliquer une action derrière - plusieurs nœuds Condition pour un OU. Dans le formulaire d'un nœud - Condition, le bouton "+ Ajouter une condition" ajoute une clause - supplémentaire (Objet / Ligne / Champ / Condition / Valeur, comme la - clause principale) et fait apparaître un sélecteur "Combiner les - conditions avec : ET / OU". Exemple : "réputation < 20 OU niveau ≥ 3". - Compatibilité : un nœud créé AVANT cette fonctionnalité (ou un nœud à une - seule clause) n'a pas de `cond_clauses` en base et garde exactement son - ancien comportement (une seule comparaison) — voir `cond_clauses` / - `cond_combinator` dans `screens/flow/ensure_flow_schema.py` et - `evaluateConditionNode()` / `evaluateConditionClause()` dans - `templates/play.html`. - -## Survol, séquences temporisées, surbrillance, overlay, verrouillage - -Cinq briques de confort, chacune contournable en théorie mais coûteuse en -câblage manuel sans elles : - -- **Interactions au survol** : un nouveau réglage "Texte affiché au survol - de la souris", disponible sur TOUS les widgets (via UNIVERSAL_CONTROLS — - voir `screens/widgets/control_groups/hover_controls.py`), échange le - texte affiché contre ce texte alternatif tant que la souris survole - l'élément, puis le restaure au départ de la souris — exactement le cas - d'usage visé ("survoler un nom révèle l'adresse réelle, survoler un lien - révèle l'URL réelle"). Laissé vide = aucun changement, donc aucune - régression sur les éléments déjà créés. Ignoré volontairement sur les - éléments qui ont des enfants (conteneurs), pour ne jamais écraser une - mise en page imbriquée. Voir l'attribut `data-hover-text` posé par - `render_element_html.py` et `bindHoverTexts()` dans `templates/play.html`. -- **Séquences temporisées** : nouveau type d'action "Attendre quelques - secondes avant de continuer" (`attendre`), qui suspend la SUITE du fil de - logique (les nœuds reliés après lui) pendant N secondes avant de - continuer — combiné au déclencheur "À l'affichage de l'écran" (voir plus - haut), ça permet un mail ou un événement qui "arrive" tout seul quelques - secondes après l'ouverture de l'écran, sans action du joueur. Le joueur - continue d'interagir normalement avec le reste de l'écran pendant - l'attente. Voir la branche `'attendre'` de `runActionNode()` dans - `templates/play.html`. -- **Surbrillance générique dynamique** : nouvelle valeur "Surbrillance" - pour l'action "Modifier un élément", applicable à N'IMPORTE QUEL élément - (pas seulement les boutons) — pose un liseré doré clignotant (`.forgeHighlight`, - animation CSS) pour attirer l'attention du joueur vers ce qu'il doit - toucher ensuite (ex. un mentor qui "montre" un bouton), sans avoir à - poser une bordure de couleur togglée à la main. Activer / Désactiver / - Basculer, comme la Visibilité. -- **Overlay / modale réutilisable** : nouveau widget "Superposition / boîte - de dialogue" (icône 🪟), prêt à l'emploi — contrairement à tous les autres - widgets, sa position ne dépend PAS de l'endroit où il est glissé sur le - canevas (`position:fixed; inset:0`, codé en dur) : il couvre TOUJOURS tout - l'écran, avec un voile semi-transparent et une boîte centrée qui contient - les éléments posés à l'intérieur (comme un conteneur normal). Démarre - MASQUÉ par défaut à sa création (sinon il couvrirait l'écran dès qu'on le - pose) — l'ouverture et la fermeture réutilisent l'action existante - "Modifier un élément → Visibilité" (rendre visible / masquer), fermeture - manuelle uniquement, sans mécanisme de clic-en-dehors-pour-fermer. Voir - `screens/rendering/render_overlay.py`. -- **Verrouillage d'un élément après décision** : nouvelle valeur - "Désactivé" pour l'action "Modifier un élément", DISTINCTE de - "Invisible" — l'élément reste visible mais devient grisé et inerte - (`.forgeDisabled` : opacité réduite + `pointer-events:none`, qui bloque - aussi le déclencheur "Au clic" éventuellement posé dessus). Utile pour un - bouton "Répondre" qui reste affiché mais ne doit plus pouvoir être - recliqué une fois le mail traité. Activer / Désactiver / Basculer. - -## Structure - -Le code est organisé en un fichier par fonction et un dossier par -responsabilité — pensé pour rester modifiable, testable et évolutif à -mesure que le moteur grandit, plutôt que quelques gros fichiers monolithiques. -`app.py` reste le point d'entrée (`python3 app.py` ne change pas), mais il -n'est plus qu'un mince assemblage : toute la logique vit dans les dossiers -ci-dessous. +## Structure du code ``` -app.py point d'entrée : assemble core/ + routes/, lance le serveur +app.py point d'entrée : assemble core/ + routes/, lance le serveur -core/ pièces transverses de l'application Flask - flask_app.py crée l'instance Flask partagée (app) - jinja_filters.py enregistre les filtres Jinja (colname, elstyle, elabel) +core/ pièces transverses (instance Flask, filtres Jinja, CSRF, auth) +filters/ filtres Jinja restants (colname) -filters/ un fichier par filtre Jinja - colname_filter.py - element_style_filter.py - -routes/ un fichier par route HTTP, regroupées par domaine - games/ créer/renommer/supprimer un jeu, tableau de bord - objects/ définitions d'objet, champs, données (CRUD) - screens/ écrans du jeu (liste, création, édition...) - elements/ éléments posés sur un écran - element_types/ catalogue d'éléments de jeu réutilisables - flow/ éditeur de logique à nœuds (déclencheur/condition/action) - legacy_actions/ ancien système d'actions par élément (conservé, non utilisé par l'interface actuelle) +routes/ une route HTTP = un fichier, regroupées par domaine + auth/ inscription, connexion, 2FA, mot de passe + onboarding/ parcours de création guidé ("Quel jeu veux-tu créer ?") + games/ créer/renommer/supprimer un jeu, tableau de bord ("Mes écrans") + screens/ écrans du jeu (liste, création, renommage, taille de scène...) + scenes/ objets de scène (personnage/décor/fond/widgets), rôle, collision, nom... + quests/ quêtes + dialogues + custom_events/ événements personnalisés (déclenchés/écoutés depuis une scène) + flow/, flow_blocks/ moteur de logique à nœuds (partagé, utilisé par les règles de collision) + animations/ clips d'animation (Animate.css + images-clés) + global_vars/ variables globales du jeu + publish/ export SCORM + play/, public_play/ mode jouable uploads/ envoi de fichiers (images/vidéos) - play/ mode jouable -db/ couche données "objets" — un fichier par fonction - connection.py, slugify.py, table_name_for.py, game_dir.py, db_path.py, constants.py - games/ cycle de vie d'un jeu - definitions/ définitions d'objet et leurs champs - rows/ lignes de données (CRUD) +db/ couche données bas niveau (SQLite, un fichier .db par jeu) + games/ cycle de vie d'un jeu, catalogue d'onboarding + definitions/, rows/ objets de données typés + leurs lignes (utilisés par le flow) + quests/ quêtes et leur dialogue + custom_events/, global_vars/, scoring/ -screens/ couche données + rendu "écrans de jeu" — un fichier par fonction - widgets/ catalogue de widgets, réglages ("controls"), groupes de réglages - rendering/ construction du HTML réel d'un élément (récursif) - screens_repo/ CRUD des écrans - elements/ CRUD des éléments posés sur un écran - element_types/ catalogue d'éléments de jeu réutilisables (conteneurs imbriqués) - flow/ moteur de logique à nœuds - data_actions/ exécution d'une action "modifier une donnée" - legacy_actions/ ancien système d'actions (conservé) - labels/ constantes d'affichage (libellés) - payload/ assemble les données du mode jouable +screens/ couche rendu — un fichier par fonction + scenes/ objets de scène : ajout, rendu, rôle, collision, commandes, dialogue... + rendering/ personnage (data/animations/rôle), collision, dialogue, quêtes + flow/ moteur de logique à nœuds (partagé) + data_actions/ exécution d'une action de flow (modifier une donnée, un score...) + animations/, custom_events/, labels/, payload/, screens_repo/ -templates/ pages HTML (Jinja2) — inchangé -static/ CSS + JS — inchangé -projects/ un sous-dossier par jeu créé (généré à l'usage) — inchangé +templates/ pages HTML (Jinja2) +static/ CSS + JS (static/js/scenes/ = éditeur de scène, static/js/play/ = moteur de jeu) +projects/ un sous-dossier par jeu créé (généré à l'usage, non versionné) -tests/ suite de tests automatisés (pytest) - conftest.py fixtures partagées (client Flask de test, jeu de test jetable) - test_db_layer.py couche données, sans Flask - test_screens_and_elements.py écrans/éléments/éléments de jeu/répéteur, via le client de test - test_flow.py éditeur de logique à nœuds +tests/ suite pytest ``` -Chaque `import db` / `import screens` continue de fonctionner exactement -comme avant (`db.connect(...)`, `screens.render_element_html(...)`, etc.) : -`db/__init__.py` et `screens/__init__.py` réexportent l'intégralité de -l'API publique historique, pour que ce découpage interne reste invisible du -reste du moteur — aucune route, aucun template n'a eu besoin de changer. - -### Lancer les tests +## Tests ```bash pip install -r requirements-dev.txt pytest ``` -## Corrections (retours d'usage) +## Notes techniques -- **ID d'élément en champ non modifiable** : dans le panneau de propriétés, - l'ID de l'élément (`elt-…`) est maintenant affiché dans un champ texte - `readonly`, plutôt qu'en simple texte. -- **Bouton "+10 points" instable en mode Jouer (corrigé)** : un bouton relié - à une action "Modifier une donnée" (ex: incrémenter un score) pouvait, après - plusieurs clics, appliquer l'effet plusieurs fois d'un coup (+100, -20, ou - retomber à 0 au lieu de ±10). Cause : chaque donnée modifiée déclenche un - rafraîchissement qui ré-attache les gestionnaires de clic sur toute la - scène ; un bouton ordinaire (contrairement à un Répéteur ou une Jauge) garde - le même nœud HTML d'un rafraîchissement à l'autre, donc ses gestionnaires de - clic s'empilaient au lieu d'être remplacés. Un clic fini par déclencher - l'action N fois pour N rafraîchissements passés. Corrigé en marquant chaque - élément déjà relié, pour ne l'attacher qu'une seule fois. -- **Textes d'aide allégés** : les explications détaillées entre parenthèses - affichées un peu partout dans l'éditeur ont été retirées ou raccourcies — - sur tous les écrans, pas seulement l'éditeur de scène (nouvelle passe - complète sur tous les labels, options de menus déroulants et placeholders). -- **Espacement (gap) inefficace en disposition par défaut, corrigé** : le - réglage "Espace entre les éléments" n'avait aucun effet tant que la - "Disposition interne" restait sur "Empilement libre" — le CSS `gap` ne - fonctionne que sur un conteneur flex/grid, or "Empilement libre" ne posait - aucun `display:flex`. Un conteneur/répéteur/groupe de champs applique - maintenant flex + colonne par défaut dès qu'aucune disposition n'a été - choisie explicitement (visuellement identique à l'empilement d'avant), - donc l'espacement fonctionne dès la création, sans avoir à toucher la - disposition au préalable. -- **Marges et bordures réglables par côté** : en plus des réglages globaux - existants ("Marge intérieure", "Marge extérieure", "Épaisseur de la - bordure"), chaque côté (haut/droite/bas/gauche) peut désormais être réglé - individuellement — utile par exemple pour une seule bordure basse, ou un - padding asymétrique. Les réglages par côté, une fois utilisés, priment sur - le réglage global correspondant. -- **Apparence de l'éditeur après suppression d'un élément** : supprimer un - élément qui a un parent (posé à l'intérieur d'un conteneur/répéteur/groupe - de champs) resélectionne maintenant ce parent, au lieu de laisser le - panneau de propriétés vide. - -- **Timeline d'animation (Animate.css + animations personnalisées)** : dans - l'éditeur d'écran, un nouveau panneau rétractable "🎬 Timeline d'animation" - est positionné sous "🔀 Logique de la scène". Il permet de poser des clips - d'animation sur n'importe quel élément de l'écran, sur un axe temporel en - secondes (depuis l'affichage de l'écran) : - - **Animations Animate.css** : choix parmi le catalogue complet de la - bibliothèque (apparitions, rebonds, glissements, zooms, rotations, - retournements, etc.), avec durée, délai, easing et nombre de répétitions - réglables. - - **Animations personnalisées** : un éditeur d'images-clés (keyframes) — - chaque image-clé a un pourcentage (0-100) et une liste de propriétés CSS - (ex. `transform: scale(1.2); opacity: 0.5;`), converties en `@keyframes` - CSS au moment de la lecture. - - Chaque clip se pose et se règle à la souris directement sur la - timeline : glisser un clip change son instant de départ, glisser son - bord droit change sa durée ; cliquer dessus (sans glisser) ouvre son - formulaire d'édition détaillé, avec un bouton "▶ Aperçu" pour voir - l'animation directement sur le canevas d'édition. - - En mode Jouer, chaque clip se déclenche automatiquement à son instant de - départ lors de l'affichage de l'écran qui le contient. -- **Échelle/animation d'un élément posé sur l'écran, corrigée** : l'échelle - ("Disposition" → curseur "Échelle") et les animations de la timeline - restaient visuellement "coincées" à l'intérieur de la boîte d'origine de - l'élément, comme si l'effet s'appliquait à un contenu emprisonné dans un - cadre immobile — c'était bien le cas : ces réglages vivaient sur la balise - intérieure, alors que le cadre qui porte réellement la position/taille de - l'élément sur l'écran (et son contour de sélection dans l'éditeur) restait - fixe autour. L'échelle et les animations visent maintenant ce cadre - directement, comme le reste de l'élément — plus de recadrage parasite. Un - élément posé À L'INTÉRIEUR d'un conteneur/répéteur/groupe de champs n'a - jamais eu ce souci (pas de cadre séparé pour lui). -- **Format d'aperçu (Portrait/Paysage/Carré) mémorisé par écran** : ce choix - ne vivait qu'en mémoire dans le navigateur — poser ou supprimer un - élément, ou sélectionner un autre élément, redemande ce panneau au - serveur, qui repartait alors systématiquement sur Portrait. Résultat : - l'écran de travail semblait "s'agrandir ou rétrécir" au fil des actions - quand on travaillait en Paysage ou en Carré (retour brutal en Portrait à - chaque fois). Le format choisi est maintenant enregistré par écran et - restauré à chaque fois que ce panneau est réaffiché. -- **Zone de jeu à proportion fixe en mode Jouer, contre la déformation en - %** : les positions et tailles des éléments sont en pourcentage de - l'écran, ce qui les déformait dès que la fenêtre du joueur n'avait pas - exactement la même proportion que celle choisie à la conception (ex. un - écran conçu en Portrait, joué dans une fenêtre large, étirait/écrasait - tout). La zone de jeu garde maintenant toujours la proportion de l'écran - affiché (Portrait/Paysage/Carré, par écran), avec des bandes noires - (letterboxing) si besoin — comme un lecteur vidéo — pour rester fidèle à - ce qui a été conçu, quel que soit l'écran du joueur. - -## Prochaines fonctionnalités - -À ajouter au fur et à mesure, comme convenu. +- **Navigation sans rechargement** : la plupart des actions de l'éditeur + de scène (ajout/suppression d'objet, sélection, changement de rôle/nom, + règles de collision) passent par des appels `fetch()` dédiés qui ne + touchent que le fragment de page concerné. La navigation entre pages + différentes (ex. "Mes écrans" ↔ une scène) passe par `static/pjax.js` + (interception des clics/formulaires, remplacement de `<main>` + + `#pageChrome`) plutôt qu'un rechargement complet — toute règle CSS dont + dépend une page doit donc vivre dans `static/style.css` (chargé + partout), jamais dans un `<style>` inline propre à cette seule page, + qui ne serait pas repris lors d'une navigation pjax vers elle. +- **Serveur de développement en mode `threaded=True`** (`app.py`) : une + scène charge plusieurs images en parallèle (personnages, fond) — sans + ce réglage, le serveur Werkzeug mono-thread les sert une par une. diff --git a/core/auth_guard.py b/core/auth_guard.py index add41e39..ec82e5bc 100644 --- a/core/auth_guard.py +++ b/core/auth_guard.py @@ -1,9 +1,10 @@ """Garde d'accès globale — connexion obligatoire pour tout le moteur, et -isolation par utilisateur d'un SEUL projet (sauf le rôle "admin", -illimité comme avant l'authentification). Un seul before_request plutôt -qu'un décorateur à poser sur chacune des ~80 routes existantes : moins de -risque d'en oublier une, et aucun fichier de routes existant n'a besoin -d'être modifié. +isolation par PROPRIÉTAIRE de projet, pour TOUS les rôles (y compris +"admin" — voir plus bas, un admin garde d'autres privilèges mais plus +aucun accès aux projets des autres comptes, faille corrigée). Un seul +before_request plutôt qu'un décorateur à poser sur chacune des ~80 routes +existantes : moins de risque d'en oublier une, et aucun fichier de routes +existant n'a besoin d'être modifié. Cet import doit avoir lieu APRÈS `import routes` (voir app.py/conftest.py) pour que `app.url_map` connaisse déjà toutes les routes au moment où ce @@ -13,6 +14,7 @@ rester cohérent avec l'ordre d'import du reste du moteur.""" from flask import g, redirect, request, session, url_for, abort import auth +from db.games.project_slug import split_slug from .flask_app import app @@ -53,11 +55,40 @@ def _require_login_and_enforce_project_isolation(): return redirect(url_for("login")) g.current_user = user - # Un compte "user" n'a accès qu'à SON SEUL projet (project_slug) — un - # "admin" reste illimité, exactement comme avant l'authentification. - # Les routes de gestion multi-jeux (page d'accueil, "+ Nouveau jeu") - # n'ont pas leur place pour un compte à projet unique : redirigées - # directement vers son propre tableau de bord plutôt qu'un 403 sec. + # Propriété d'un projet = le segment "propriétaire" de son slug (voir + # db/games/project_slug.py — projects/<owner_folder>/<projet>/, + # owner_folder est l'id du compte qui l'a créé, voir + # db/games/create_game.py) comparé à l'id du compte CONNECTÉ — pour + # TOUS les rôles, admin compris. Corrige une faille : auparavant ce + # contrôle (comme tout le reste de cette fonction) était sauté pour + # "admin", qui pouvait donc ouvrir/modifier/supprimer le projet de + # N'IMPORTE QUEL autre compte en connaissant son slug. + # + # Un slug "à plat" (un seul segment, `split_slug` renvoie alors + # `project_part=None`) n'a jamais été rattaché à un compte précis — + # avant l'introduction de project_slug, c'était déjà la norme + # (voir scripts/migrate_flat_project_slugs.py), et `db.create_game()` + # sans `owner_folder` produit encore ce format aujourd'hui (utilisé + # par une bonne partie de la suite de tests comme jeu jetable, sans + # compte associé). Réservé au rôle "admin" (repli sur le comportement + # "illimité" historique pour ce cas précis) — jamais un "user", qui + # n'a par construction aucun projet à plat légitime. + slug = request.view_args.get("slug") if request.view_args else None + if slug is not None: + owner_folder, project_part = split_slug(slug) + if project_part is None: + if user["role"] != "admin": + abort(403) + elif owner_folder != str(user["id"]): + abort(403) + + # Un compte "user" n'a accès qu'à SON SEUL projet (project_slug, + # simple raccourci de confort ici — la sécurité elle-même est déjà + # assurée ci-dessus) — un "admin" peut en avoir plusieurs, exactement + # comme avant l'authentification. Les routes de gestion multi-jeux + # (page d'accueil, "+ Nouveau jeu") n'ont pas leur place pour un + # compte à projet unique : redirigées directement vers son propre + # tableau de bord plutôt qu'un 403 sec. if user["role"] != "admin": if endpoint in ("index", "games_new") and user.get("project_slug"): return redirect(url_for("game_dashboard", slug=user["project_slug"])) @@ -68,9 +99,6 @@ def _require_login_and_enforce_project_isolation(): # une connexion, absentes de _PUBLIC_ENDPOINTS). if not user.get("project_slug") and endpoint not in _REACHABLE_WITHOUT_PROJECT: return redirect(url_for("onboarding_new")) - slug = request.view_args.get("slug") if request.view_args else None - if slug is not None and slug != user.get("project_slug"): - abort(403) # Type d'onboarding "restreint" (quiz/embranchement/rpg, voir # db/games/game_type_catalog.py) : game_dashboard reste atteignable # comme pour "custom", mais routes/games/game_dashboard.py y rend diff --git a/db/games/create_game.py b/db/games/create_game.py index 9dd29eeb..1b6b7425 100644 --- a/db/games/create_game.py +++ b/db/games/create_game.py @@ -11,8 +11,9 @@ def create_game(name, owner_folder=None, project_slug_override=None): reliés entre eux, et sa base de données dédiée (nom du jeu en méta). owner_folder : dossier PROPRIÉTAIRE (voir db/games/project_slug.py — - structure de dossiers par utilisateur), typiquement - slugify(email_du_compte) — le slug final composé + structure de dossiers par utilisateur), l'id du compte en pratique + (voir routes/games/games_new.py — opaque, jamais dérivé de l'email) — + le slug final composé "<owner_folder>_<project_part>" résout vers un vrai chemin imbriqué projects/<owner_folder>/<project_part>/. Omis (None) : repli sur l'ancien comportement plat (slug à un seul segment, projects/<slug>/) diff --git a/db/games/list_games.py b/db/games/list_games.py index 19b34ec6..06f465ef 100644 --- a/db/games/list_games.py +++ b/db/games/list_games.py @@ -6,27 +6,25 @@ from ..db_path import db_path from .project_slug import build_slug -def list_games(): - """Scanne projects/ : structure par utilisateur (voir - db/games/project_slug.py) — projects/<propriétaire>/<projet>/game.db, - deux niveaux — plus le repli sur l'ancien rangement plat - projects/<slug>/game.db pour tout slug pas encore migré (voir - scripts/migrate_flat_project_slugs.py).""" +def list_games(owner_folder): + """Scanne UNIQUEMENT projects/<owner_folder>/ (voir db/games/ + project_slug.py — owner_folder est l'id du compte, voir + db/games/create_game.py) : jamais les autres comptes — voir + core/auth_guard.py, qui vérifie déjà que `owner_folder` correspond au + compte connecté avant tout accès à un slug composé de cette valeur. + Un appelant qui passerait le mauvais owner_folder ne verrait de toute + façon que SES PROPRES projets, jamais ceux d'un autre compte : cette + fonction ne lit plus jamais l'arborescence project/ en entier (avant + ce correctif, elle listait TOUS les comptes sans distinction — faille + corrigée, voir routes/games/index.py).""" games = [] - if not os.path.isdir(PROJECTS_DIR): + entry_path = os.path.join(PROJECTS_DIR, owner_folder) + if not os.path.isdir(entry_path): return games - for entry in sorted(os.listdir(PROJECTS_DIR)): - entry_path = os.path.join(PROJECTS_DIR, entry) - if not os.path.isdir(entry_path): - continue - if os.path.isfile(os.path.join(entry_path, "game.db")): - # Repli : un ancien dossier plat porte directement game.db. - _append_game(games, entry) - continue - for project_part in sorted(os.listdir(entry_path)): - slug = build_slug(entry, project_part) - if os.path.isfile(db_path(slug)): - _append_game(games, slug) + for project_part in sorted(os.listdir(entry_path)): + slug = build_slug(owner_folder, project_part) + if os.path.isfile(db_path(slug)): + _append_game(games, slug) return games diff --git a/db/games/move_game.py b/db/games/move_game.py index a8ecd869..2fd6db05 100644 --- a/db/games/move_game.py +++ b/db/games/move_game.py @@ -6,17 +6,18 @@ from .project_slug import build_slug, split_slug def move_game(old_slug, new_owner_folder): - """Renomme le dossier PROPRIÉTAIRE d'un compte — utilisé quand un - utilisateur change son adresse email (voir routes/auth/profile.py, - owner_folder = slugify(email) pour un compte "user", voir - db/games/project_slug.py) : le dossier physique doit suivre. Renomme - le dossier propriétaire ENTIER en un coup (déplace tous les projets de - ce compte ensemble — prêt pour un futur multi-projet, même si un seul - aujourd'hui), pas juste un projet. Rend le nouveau dossier propriétaire - unique de la même façon que create_game() si, par un hasard extrême, - il correspond déjà à un dossier existant. Renvoie le nouveau slug - composé FINAL (même projet, propriétaire renommé), à enregistrer comme - nouveau project_slug.""" + """Renomme le dossier PROPRIÉTAIRE d'un compte — owner_folder est + l'id du compte (voir db/games/project_slug.py, routes/games/ + games_new.py) : ne change donc plus jamais après coup en usage normal, + mais reste utile pour une migration ponctuelle (voir + scripts/migrate_owner_folders_to_user_id.py, qui a besoin de renommer + les anciens dossiers slugify(email) vers l'id du compte). Renomme le + dossier propriétaire ENTIER en un coup (déplace tous les projets de ce + compte ensemble), pas juste un projet. Rend le nouveau dossier + propriétaire unique de la même façon que create_game() si, par un + hasard extrême, il correspond déjà à un dossier existant. Renvoie le + nouveau slug composé FINAL (même projet, propriétaire renommé), à + enregistrer comme nouveau project_slug.""" old_owner_folder, project_part = split_slug(old_slug) if project_part is None: # Slug pas encore migré vers la structure par utilisateur (voir diff --git a/routes/auth/profile.py b/routes/auth/profile.py index 3bced677..29e70800 100644 --- a/routes/auth/profile.py +++ b/routes/auth/profile.py @@ -43,15 +43,9 @@ def profile_update_email(): except auth.EmailUpdateError as exc: return _render(error=str(exc)) - # Le dossier d'un compte "user" porte le nom de son adresse email (voir - # create_user.py/register_2fa.py, project_slug = slugify(email)) — il - # doit suivre le changement, sans quoi project_slug ne correspondrait - # plus à aucun dossier réel. Un "admin" n'a pas de project_slug dédié : - # rien à renommer pour ce rôle. - if user["role"] != "admin" and user.get("project_slug"): - new_slug = db.move_game(user["project_slug"], db.slugify(new_email)) - auth.set_project_slug(user["id"], new_slug) - + # Le dossier d'un projet est nommé d'après l'ID du compte, jamais son + # email (voir routes/games/games_new.py) — rien à renommer sur disque + # quand l'email change, pour aucun rôle. return _render(success="Adresse email mise à jour.") diff --git a/routes/games/games_new.py b/routes/games/games_new.py index 72d354b6..2fa82f13 100644 --- a/routes/games/games_new.py +++ b/routes/games/games_new.py @@ -15,7 +15,9 @@ def games_new(): # avant (l'admin en crée via "+ Nouvel écran" dans le dashboard, en # choisissant "document" ou "jeu_2d" pour chacun). # owner_folder (voir db/games/project_slug.py — structure de dossiers - # par utilisateur) : posé par core/auth_guard.py pour tout compte - # connecté, y compris l'admin qui utilise cette route. - slug = db.create_game(name, owner_folder=db.slugify(g.current_user["email"])) + # par utilisateur) : l'id du compte, JAMAIS son email — opaque, ne + # révèle rien du compte propriétaire dans les slugs/URLs (voir + # scripts/migrate_owner_folders_to_user_id.py pour les projets déjà + # existants avec l'ancien schéma). + slug = db.create_game(name, owner_folder=str(g.current_user["id"])) return redirect(url_for("game_dashboard", slug=slug)) diff --git a/routes/games/index.py b/routes/games/index.py index 22aa9d1a..64dac2c6 100644 --- a/routes/games/index.py +++ b/routes/games/index.py @@ -1,4 +1,4 @@ -from flask import render_template +from flask import g, render_template import db @@ -7,4 +7,7 @@ from core.flask_app import app @app.route("/") def index(): - return render_template("index.html", games=db.list_games()) + # Toujours SES PROPRES projets (voir db.list_games, corrigé pour ne + # plus jamais scanner les autres comptes) — même pour un admin, qui + # n'a donc plus aucune visibilité sur les projets des autres comptes. + return render_template("index.html", games=db.list_games(str(g.current_user["id"]))) diff --git a/routes/onboarding/onboarding_new.py b/routes/onboarding/onboarding_new.py index d0f04212..987f4ea1 100644 --- a/routes/onboarding/onboarding_new.py +++ b/routes/onboarding/onboarding_new.py @@ -54,7 +54,9 @@ def onboarding_new(): def _create_project_for_user(user, onboarding_type, name): - slug = db.create_game(name, owner_folder=db.slugify(user["email"])) + # owner_folder = l'id du compte, jamais son email (voir + # routes/games/games_new.py, même convention). + slug = db.create_game(name, owner_folder=str(user["id"])) db.set_onboarding_type(slug, onboarding_type) if user["role"] != "admin": # project_slug = LE seul projet d'un compte "user" (voir diff --git a/scripts/backfill_onboarding_type.py b/scripts/backfill_onboarding_type.py deleted file mode 100644 index 00b29fe6..00000000 --- a/scripts/backfill_onboarding_type.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Script à usage unique (PAS exécuté au runtime du moteur) : pose la clé -_meta['onboarding_type'] sur tout projet existant qui ne l'a pas encore -(créé avant l'existence de l'onboarding guidé — voir routes/onboarding/ -onboarding_new.py et db/games/game_type_catalog.py). Pas strictement -nécessaire : get_onboarding_type() retombe déjà sur DEFAULT_ONBOARDING_TYPE -("custom") pour tout projet sans cette clé — ce script rend juste ce choix -EXPLICITE dans les données plutôt qu'implicite, sans rien changer au -comportement observable. - -Marque "custom" tout projet trouvé sans onboarding_type (comportement -neutre : tableau de bord complet garanti visible, comme aujourd'hui) — -aucune distinction "jeu_2d"/RPG à faire ici, cette notion n'existe plus au -niveau projet depuis la fusion des moteurs (écran par écran, voir -screens/screens_repo/ensure_schema.py) : un projet historiquement "jeu_2d" -reste "custom" comme n'importe quel autre, ses écrans gardent -individuellement leur kind="jeu_2d". - - python scripts/backfill_onboarding_type.py -""" -import os -import sys - -_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -sys.path.insert(0, _BASE_DIR) - -import db # noqa: E402 - - -def backfill(): - games = db.list_games() - updated = 0 - for g in games: - if db.get_onboarding_type_raw(g["slug"]) is None: - db.set_onboarding_type(g["slug"], "custom") - updated += 1 - print(f"{g['slug']!r} -> 'custom'") - print(f"{updated}/{len(games)} projet(s) mis à jour.") - - -if __name__ == "__main__": - backfill() diff --git a/scripts/migrate_owner_folders_to_user_id.py b/scripts/migrate_owner_folders_to_user_id.py new file mode 100644 index 00000000..110cea1c --- /dev/null +++ b/scripts/migrate_owner_folders_to_user_id.py @@ -0,0 +1,103 @@ +"""Script à usage unique (PAS exécuté au runtime du moteur) : renomme le +dossier PROPRIÉTAIRE de chaque compte (projects/<owner_folder>/, voir +db/games/project_slug.py) de l'ancien schéma slugify(email) — visible en +clair dans chaque slug/URL de l'app, ex. projects/vandal-william-gmail-com/ — +vers l'id numérique du compte (opaque), ex. projects/4/. + +core/auth_guard.py vérifie désormais que le segment "propriétaire" d'un +slug correspond à l'id du compte connecté (pour TOUS les rôles, y compris +"admin" — faille corrigée) : cette migration doit avoir tourné avant de +déployer ce correctif, sinon aucun compte existant ne pourrait plus +accéder à ses propres projets (leur dossier reste sous l'ancien nom tant +que ce script n'a pas tourné). + +Pour chaque compte : le dossier propriétaire ATTENDU est déduit de +`project_slug` (compte "user", déjà à jour si son email a changé entre +temps) ou, à défaut (compte "admin", qui n'utilise pas cette colonne — ou +tout compte sans projet), de `slugify(email)` (l'ancienne convention, +utilisée pour TOUS les rôles jusqu'à ce correctif). S'il existe sur +disque, il est renommé en `str(user_id)` via db.move_game (déplace TOUS +les projets de ce compte en un coup), puis project_slug est mis à jour +pour les comptes "user". + +Mode simulation par défaut (n'écrit rien, affiche ce qui serait fait) — +`--apply` requis pour exécuter réellement : + python scripts/migrate_owner_folders_to_user_id.py # aperçu + python scripts/migrate_owner_folders_to_user_id.py --apply # exécute +""" +import os +import sys + +_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _BASE_DIR) + +import db # noqa: E402 +import auth # noqa: E402 +from db.constants import PROJECTS_DIR # noqa: E402 +from db.games.project_slug import build_slug, split_slug # noqa: E402 +from auth.connection import connect as connect_auth_db # noqa: E402 + + +def _expected_old_owner_folder(user): + if user["role"] != "admin" and user["project_slug"]: + owner_folder, project_part = split_slug(user["project_slug"]) + if project_part is not None: + return owner_folder + # Repli : compte "admin" (pas de project_slug dédié) ou compte "user" + # sans projet_slug ("user" fraîchement inscrit, pas encore passé par + # l'onboarding — n'a alors aucun dossier à migrer de toute façon) — + # l'ancienne convention posait TOUJOURS owner_folder = slugify(email), + # quel que soit le rôle (voir routes/games/games_new.py avant ce + # correctif). + return db.slugify(user["email"]) + + +def migrate(apply=False): + conn = connect_auth_db() + users = [ + dict(r) for r in + conn.execute("SELECT id, email, role, project_slug FROM _users").fetchall() + ] + conn.close() + + migrated = 0 + for user in users: + new_owner_folder = str(user["id"]) + old_owner_folder = _expected_old_owner_folder(user) + if old_owner_folder == new_owner_folder: + continue # déjà migré (ou coïncidence — rien à faire non plus) + old_dir = os.path.join(PROJECTS_DIR, old_owner_folder) + if not os.path.isdir(old_dir): + continue # aucun projet pour ce compte — rien à déplacer + + project_parts = sorted( + entry for entry in os.listdir(old_dir) + if os.path.isfile(os.path.join(old_dir, entry, "game.db")) + ) + if not project_parts: + print(f"{user['email']} ({user['role']}) : dossier {old_owner_folder!r} sans jeu — ignoré.") + continue + + sample_slug = build_slug(old_owner_folder, project_parts[0]) + print( + f"{user['email']} ({user['role']}) : {old_owner_folder!r} -> {new_owner_folder!r}" + f" ({len(project_parts)} projet(s) : {', '.join(project_parts)})" + ) + if not apply: + continue + + final_slug = db.move_game(sample_slug, new_owner_folder) + if user["role"] != "admin": + # Un compte "user" n'a qu'UN SEUL projet — le slug renvoyé + # pour celui-ci est donc directement le nouveau project_slug. + auth.set_project_slug(user["id"], final_slug) + migrated += 1 + + if apply: + print(f"{migrated} compte(s) migré(s).") + else: + print("Aperçu seulement — relance avec --apply pour exécuter.") + + +if __name__ == "__main__": + migrate(apply="--apply" in sys.argv) diff --git a/tests/conftest.py b/tests/conftest.py index 29e02d0e..534d34f3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -97,17 +97,19 @@ def user_client(): @pytest.fixture -def user_game(client, user_client): - """Un jeu appartenant au compte `user_client` (project_slug assigné, - voir auth.set_project_slug — même mécanisme que l'inscription réelle, - routes/auth/register_2fa.py). Créé via le client ADMIN (`client`, - illimité) puis rattaché, pour ne pas dépendre du parcours - d'inscription complet dans les tests qui n'en ont pas besoin.""" - resp = client.post("/games/new", data={"name": "pytest_user_game"}, follow_redirects=False) - assert resp.status_code == 302 - slug = resp.headers["Location"].rstrip("/").split("/")[-1] +def user_game(user_client): + """Un jeu appartenant réellement au compte `user_client` — le dossier + PROPRIÉTAIRE (voir db/games/project_slug.py) doit correspondre à + l'id de CE compte (core/auth_guard.py compare désormais les deux) : + créé directement avec `owner_folder=str(user_id)`, jamais via le + client ADMIN (qui produirait un projet appartenant à l'admin, avec + juste `project_slug` réassigné dessus côté "user" — cassait + l'isolation par propriétaire dès qu'elle a cessé d'être une simple + comparaison de chaîne).""" with user_client.session_transaction() as sess: - auth.set_project_slug(sess["user_id"], slug) + user_id = sess["user_id"] + slug = db.create_game("pytest_user_game", owner_folder=str(user_id)) + auth.set_project_slug(user_id, slug) yield slug if os.path.isdir(db.game_dir(slug)): db.delete_game(slug) diff --git a/tests/test_auth.py b/tests/test_auth.py index e33ff9e0..dd9f62bb 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,6 +1,7 @@ """Comptes utilisateurs (auth/) : inscription (nom/prénom/email unique, mot de passe fort, 2FA TOTP obligatoire), connexion, et isolation par -utilisateur d'un SEUL projet (sauf le rôle "admin", illimité — voir +PROPRIÉTAIRE de projet — pour TOUS les rôles, y compris "admin" (qui +garde seulement le droit d'avoir plusieurs projets À LUI, voir core/auth_guard.py). Le tout premier compte jamais créé devient automatiquement admin (auth/create_user.py) : la fixture `client` de conftest.py en a déjà créé un pour authentifier tous les AUTRES tests du @@ -200,6 +201,55 @@ def test_non_admin_user_is_isolated_to_their_own_project(anon_client): _cleanup_project("second") +def _create_isolated_user_with_project(email, name="Jeu de la victime"): + """Compte + projet créés directement (sans passer par le client HTTP + de test) — combiner ici deux instances de `test_client()` actives en + parallèle (la fixture `client`, admin partagé, ET une seconde pour + créer la victime) fait planter Werkzeug ("Popped wrong app context"), + une limite connue du client de test Flask quand deux contextes de + requête s'entremêlent plutôt que de s'imbriquer proprement.""" + user_id = auth.create_user(email, "Sup3r$ecret!", "Test", "Victime") + auth.confirm_totp(user_id) + slug = db.create_game(name, owner_folder=str(user_id)) + auth.set_project_slug(user_id, slug) + return auth.get_user_by_id(user_id) + + +def test_admin_cannot_access_another_users_project(client): + """Faille corrigée : le rôle "admin" contournait entièrement + l'isolation par projet (voir core/auth_guard.py) — un admin pouvait + ouvrir/modifier/supprimer le jeu de N'IMPORTE QUEL autre compte en + connaissant simplement son slug. `client` (conftest.py) est déjà + connecté en admin ; désormais bloqué exactement comme un "user".""" + victim = _create_isolated_user_with_project("adminvictim@example.com") + try: + assert client.get(f"/game/{victim['project_slug']}").status_code == 403 + assert client.get(f"/game/{victim['project_slug']}/screens/1/edit").status_code == 403 + assert client.post(f"/game/{victim['project_slug']}/delete").status_code == 403 + # Le projet de la victime doit être resté intact (la tentative de + # suppression ci-dessus doit avoir été bloquée AVANT toute écriture). + assert db.game_meta(victim["project_slug"]) is not None + finally: + _cleanup_project("adminvictim@example.com") + + +def test_admin_home_page_only_lists_their_own_projects(client, game): + """routes/games/index.py (page "Mes jeux") scannait auparavant TOUT + projects/ sans filtrage (db.list_games()) — n'importe quel admin + voyait donc le slug (et le nom de dossier propriétaire) de chaque + compte. Corrigé : ne renvoie plus que les projets du compte connecté, + admin compris.""" + victim = _create_isolated_user_with_project("adminlistvictim@example.com") + try: + resp = client.get("/") + assert resp.status_code == 200 + html = resp.get_data(as_text=True) + assert game in html + assert victim["project_slug"] not in html + finally: + _cleanup_project("adminlistvictim@example.com") + + def test_non_admin_user_cannot_delete_their_only_project(anon_client): """Sans issue de secours (games_new renvoie toujours vers son project_slug, existant ou non), le supprimer serait un piège sans diff --git a/tests/test_db_layer.py b/tests/test_db_layer.py index a401470e..521003e2 100644 --- a/tests/test_db_layer.py +++ b/tests/test_db_layer.py @@ -17,7 +17,16 @@ def test_create_game_and_meta(tmp_game_slug_cleanup): tmp_game_slug_cleanup(slug) meta = db.game_meta(slug) assert meta["name"] == "Mon Jeu De Test" - assert slug in [g["slug"] for g in db.list_games()] + + +def test_list_games_only_returns_the_given_owners_projects(tmp_game_slug_cleanup): + """db.list_games(owner_folder) ne scanne plus jamais projects/ en + entier (voir routes/games/index.py — faille corrigée : un admin + voyait auparavant les projets de tous les comptes).""" + slug = db.create_game("Jeu propriétaire", owner_folder="999999") + tmp_game_slug_cleanup(slug) + assert slug in [g["slug"] for g in db.list_games("999999")] + assert db.list_games("000000") == [] def test_create_game_deduplicates_slug(tmp_game_slug_cleanup): diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index a7806fca..042fecf8 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -132,7 +132,8 @@ def test_admin_can_create_several_games_via_onboarding(client): slug2 = resp2.headers["Location"].split("/game/", 1)[1].split("/", 1)[0] assert slug2 != slug1 - slugs = {g["slug"] for g in db.list_games()} + import tests.conftest as conftest_module + slugs = {g["slug"] for g in db.list_games(str(conftest_module._TEST_ADMIN_ID))} assert slug1 in slugs assert slug2 in slugs finally: diff --git a/tests/test_profile.py b/tests/test_profile.py index a6e936df..ccba29c5 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -5,7 +5,6 @@ import pyotp import auth import db -from db.games.project_slug import build_slug, split_slug from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project, anon_client # noqa: F401 @@ -215,7 +214,13 @@ def test_update_email_rejects_an_email_already_used(anon_client): _cleanup_project("emailtaken2@example.com") -def test_update_email_renames_the_user_project_folder(anon_client): +def test_update_email_never_touches_the_project_folder(anon_client): + """Le dossier propriétaire d'un projet est nommé d'après l'ID du + compte (voir routes/games/games_new.py, opaque et stable — jamais + l'email, voir scripts/migrate_owner_folders_to_user_id.py pour la + migration qui a retiré l'ancien schéma slugify(email)) : changer + d'adresse email ne doit donc plus jamais renommer quoi que ce soit sur + disque, ni changer project_slug.""" import os _register(anon_client, "oldmail@example.com") @@ -231,13 +236,9 @@ def test_update_email_renames_the_user_project_folder(anon_client): assert auth.get_user_by_email("oldmail@example.com") is None new_user = auth.get_user_by_email("newmail@example.com") assert new_user is not None - new_slug = new_user["project_slug"] - # Structure de dossiers par utilisateur (voir db/games/ - # project_slug.py) : seul le dossier PROPRIÉTAIRE change de nom, - # le dossier projet (project_part) reste le même. - assert new_slug == build_slug(db.slugify("newmail@example.com"), split_slug(old_slug)[1]) - assert not os.path.isdir(db.game_dir(old_slug)) - assert os.path.isdir(db.game_dir(new_slug)) + # Ni le slug ni le dossier sur disque n'ont bougé. + assert new_user["project_slug"] == old_slug + assert os.path.isdir(db.game_dir(old_slug)) # La connexion se fait désormais avec la nouvelle adresse. anon_client.post("/logout") -- 2.54.0