Phase 1 (2/3) : route publique /jouer/<slug> + identité visiteur
Build and deploy / test (push) Failing after 8s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Ajoute la vraie route hébergée multi-joueurs qui manquait totalement
(voir le constat d'exploration : /game/<slug>/play est réservé au
créateur connecté, "Publier" ne génère qu'un exécutable mono-joueur) —
un visiteur anonyme peut maintenant jouer un jeu explicitement publié en
ligne, avec sa propre partie (variables/objets per_player posés dans le
commit précédent).

core/player_identity.py : identité visiteur via un cookie NON SIGNÉ
(forge_player_id, secrets.token_urlsafe(16), 1 an) — une simple clé de
partition, jamais un jeton d'autorisation.

routes/public_play/ : 4 routes, miroirs des routes existantes de l'aperçu
créateur mais threadées avec le vrai player_id du cookie au lieu du
sentinel PLAYER_SHARED :
- GET /jouer/<slug> (game_play_public.py)
- GET /jouer/<slug>/runtime-payload
- POST /jouer/<slug>/flow/nodes/<id>/run-data
- POST /jouer/<slug>/flow/nodes/<id>/run-variable
Chacune vérifie elle-même db.is_public_played(slug) (404 sinon) — un jeu
n'est exposé publiquement que si le créateur l'a explicitement basculé
"Publier en ligne" (nouveau db/games/is_public_played.py, réutilise la
table générique _meta, comme game_meta.py pour 'name').

core/auth_guard.py : les 4 endpoints publics ajoutés à _PUBLIC_ENDPOINTS
— la garde générique de connexion les laisse passer sans session, mais
chaque vue vérifie quand même is_public_played elle-même (défense en
profondeur, pas seulement une liste d'exceptions). CSRF (core/csrf_guard.py)
n'a besoin d'AUCUN changement : le jeton est déjà lié à la session Flask,
qui existe pour n'importe quel visiteur (connecté ou non).

templates/play.html : FORGE_PLAY_URLS (posé en Phase -1) ne construit
plus ses URLs via des noms de endpoint fixes (url_for('runtime_payload',
...)) mais reçoit des URLs déjà résolues par la route elle-même
(runtime_payload_url/flow_node_run_data_url/flow_node_run_variable_url)
— nécessaire puisque ce même template sert maintenant DEUX familles de
routes (aperçu créateur ET partie publique), chacune avec ses propres
noms de endpoint. routes/play/game_play.py (aperçu créateur, INCHANGÉ
comportement) et game_play_public.py passent chacun ses propres URLs.

templates/base.html : bascule "🌐 Publier en ligne" dans la barre de
navigation du jeu, à côté de "📦 Publier" (export .zip) — deux
fonctionnalités distinctes. db/games/game_meta.py expose maintenant
is_public_played, disponible partout où `game` est dans le contexte.

Vérifié : 237 tests passent (5 nouveaux dans test_public_play.py, dont un
bout-en-bout via HTTP avec deux VRAIS clients de test anonymes — deux
cookies forge_player_id différents — qui obtiennent des valeurs de
variable indépendantes, et un qui verrouille que l'aperçu créateur reste
inchangé). Syntaxe JS validée sur les deux variantes de play.html rendu
(aperçu créateur et partie publique).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-30 15:46:00 +02:00
co-authored by Claude Sonnet 5
parent 236d6b4b46
commit 3c39f1a249
18 changed files with 362 additions and 9 deletions
+9
View File
@@ -19,9 +19,18 @@ from .flask_app import app
# Endpoints accessibles SANS être connecté — tout le reste exige une
# session valide. "static" (CSS/JS/images) doit rester public : la page
# de connexion elle-même en a besoin pour s'afficher.
#
# game_play_public/runtime_payload_public/flow_node_run_data_public/
# flow_node_run_variable_public (routes/public_play/) : la route PUBLIQUE
# /jouer/<slug>, pour un vrai visiteur anonyme (état par joueur, Phase 1)
# — chaque vue vérifie ELLE-MÊME db.is_public_played(slug) (404 sinon),
# cette garde générique ne fait qu'autoriser l'accès sans connexion, elle
# ne dispense d'aucune autre vérification.
_PUBLIC_ENDPOINTS = {
"static", "login", "login_2fa", "register", "register_2fa", "logout",
"forgot_password", "reset_password",
"game_play_public", "runtime_payload_public",
"flow_node_run_data_public", "flow_node_run_variable_public",
}
+35
View File
@@ -0,0 +1,35 @@
"""Identité d'un visiteur anonyme de la route publique /jouer/<slug> (voir
routes/public_play/) — état par joueur, Phase 1. Un cookie NON SIGNÉ (pas
besoin de itsdangerous/app.secret_key ici) : c'est une simple clé de
PARTITION des données (quelles lignes/valeurs appartiennent à ce visiteur),
jamais un jeton d'autorisation — la contrefaçon la plus grave possible
serait de se faire passer pour un AUTRE joueur anonyme du même jeu, pas de
franchir une frontière de sécurité (l'aperçu créateur, lui, reste protégé
par core/auth_guard.py, complètement indépendant de ce mécanisme)."""
import secrets
from flask import request
PLAYER_ID_COOKIE = "forge_player_id"
_PLAYER_ID_MAX_AGE = 60 * 60 * 24 * 365 # 1 an
def get_or_create_player_id():
"""Renvoie (player_id, is_new) — is_new indique s'il faut poser le
cookie sur la réponse avant de la renvoyer (voir
set_player_id_cookie ci-dessous) : à utiliser aussi bien depuis une
route qui rend une page (GET /jouer/<slug>) qu'une route API JSON
(runtime-payload, run-data, run-variable), dont la réponse n'est
construite qu'APRÈS avoir déjà besoin du player_id pour agir."""
existing = request.cookies.get(PLAYER_ID_COOKIE)
if existing:
return existing, False
return secrets.token_urlsafe(16), True
def set_player_id_cookie(response, player_id):
response.set_cookie(
PLAYER_ID_COOKIE, player_id,
max_age=_PLAYER_ID_MAX_AGE, httponly=True, samesite="Lax",
)
return response
+3
View File
@@ -29,6 +29,8 @@ from .connection import connect
from .games.list_games import list_games
from .games.game_meta import game_meta
from .games.is_public_played import is_public_played
from .games.set_public_played import set_public_played
from .games.create_game import create_game
from .games.update_game_name import update_game_name
from .games.delete_game import delete_game
@@ -72,6 +74,7 @@ __all__ = [
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
"slugify", "table_name_for", "game_dir", "db_path", "connect",
"list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game",
"is_public_played", "set_public_played",
"list_definitions", "get_definition", "create_definition", "rename_definition",
"definitions_referencing", "add_field_to_definition", "update_field", "delete_field",
"delete_definition",
+9 -1
View File
@@ -1,8 +1,16 @@
from ..connection import connect
from .is_public_played import is_public_played
def game_meta(slug):
conn = connect(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
conn.close()
return {"slug": slug, "name": row["value"] if row else slug}
return {
"slug": slug, "name": row["value"] if row else slug,
# État par joueur (Phase 1) : disponible partout où `game` est
# dans le contexte du template (base.html, la bascule "Publier en
# ligne" en a besoin pour afficher son état actuel), sans avoir à
# appeler is_public_played(slug) séparément dans chaque route.
"is_public_played": is_public_played(slug),
}
+14
View File
@@ -0,0 +1,14 @@
from ..connection import connect
def is_public_played(slug):
"""Un jeu n'est jouable sur la route publique /jouer/<slug> (voir
routes/public_play/) que si le créateur l'a explicitement basculé
"Publier en ligne" dans son tableau de bord — jamais par défaut, pour
ne pas exposer un jeu en cours de conception. Réutilise la table
générique _meta (voir game_meta.py, déjà utilisée pour 'name'),
aucune nouvelle colonne/table nécessaire."""
conn = connect(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'is_public_played'").fetchone()
conn.close()
return bool(row and row["value"] == "1")
+14
View File
@@ -0,0 +1,14 @@
from ..connection import connect
def set_public_played(slug, value):
"""Bascule "Publier en ligne" (tableau de bord) — voir
is_public_played.py. `value` : bool."""
conn = connect(slug)
conn.execute(
"INSERT INTO _meta (key, value) VALUES ('is_public_played', ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
("1" if value else "0",),
)
conn.commit()
conn.close()
+1 -1
View File
@@ -1 +1 @@
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, public_play
+1 -1
View File
@@ -1 +1 @@
from . import index, games_new, game_dashboard, game_rename, game_delete
from . import index, games_new, game_dashboard, game_rename, game_delete, game_toggle_public_played
+16
View File
@@ -0,0 +1,16 @@
from flask import request, redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/toggle-public-played", methods=["POST"])
def game_toggle_public_played(slug):
"""Bascule "Publier en ligne" (voir db/games/is_public_played.py) —
active/désactive la route publique /jouer/<slug> (routes/public_play/),
jouable par n'importe quel visiteur anonyme avec sa propre partie
(état par joueur, Phase 1). Distinct de "Publier" (export .zip
exécutable mono-joueur, voir routes/publish/publish_game.py)."""
db.set_public_played(slug, request.form.get("value") == "1")
return redirect(request.referrer or url_for("game_dashboard", slug=slug))
+8 -1
View File
@@ -1,6 +1,6 @@
import json
from flask import render_template
from flask import render_template, url_for
import db
import screens
@@ -10,8 +10,15 @@ from core.flask_app import app
@app.route("/game/<slug>/play")
def game_play(slug):
"""Aperçu créateur (connecté, voir core/auth_guard.py) — une seule
partie partagée (PLAYER_SHARED, le défaut de full_game_payload), comme
avant l'état par joueur (Phase 1). Distincte de /jouer/<slug>
(routes/public_play/), la vraie route publique par joueur."""
game = db.game_meta(slug)
payload = screens.full_game_payload(slug)
return render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
runtime_payload_url=url_for("runtime_payload", slug=slug),
flow_node_run_data_url=url_for("flow_node_run_data", slug=slug, node_id=999999),
flow_node_run_variable_url=url_for("flow_node_run_variable", slug=slug, node_id=999999),
)
+1
View File
@@ -0,0 +1 @@
from . import game_play_public, runtime_payload_public, flow_node_run_data_public, flow_node_run_variable_public
@@ -0,0 +1,29 @@
from flask import jsonify, request, abort
import db
import screens
from core.flask_app import app
from core.player_identity import get_or_create_player_id, set_player_id_cookie
@app.route("/jouer/<slug>/flow/nodes/<int:node_id>/run-data", methods=["POST"])
def flow_node_run_data_public(slug, node_id):
"""Miroir public de routes/flow/flow_node_run_data.py (aperçu créateur)
— voir routes/public_play/game_play_public.py pour le principe
général. `player_id` (le cookie du visiteur) est passé à
apply_data_action pour qu'un objet per_player agisse sur SA ligne."""
if not db.is_public_played(slug):
abort(404)
player_id, is_new = get_or_create_player_id()
node = screens.get_flow_node(slug, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee":
return jsonify({"ok": False}), 400
if node.get("target_row_id") == screens.CLICKED_ROW_ID:
body = request.get_json(silent=True) or {}
node = {**node, "target_row_id": body.get("clicked_row_id")}
ok = screens.apply_data_action(slug, node, player_id)
resp = app.make_response(jsonify({"ok": ok}))
if is_new:
set_player_id_cookie(resp, player_id)
return resp
@@ -0,0 +1,25 @@
from flask import jsonify, abort
import db
import screens
from core.flask_app import app
from core.player_identity import get_or_create_player_id, set_player_id_cookie
@app.route("/jouer/<slug>/flow/nodes/<int:node_id>/run-variable", methods=["POST"])
def flow_node_run_variable_public(slug, node_id):
"""Miroir public de routes/flow/flow_node_run_variable.py (aperçu
créateur) — voir routes/public_play/game_play_public.py pour le
principe général."""
if not db.is_public_played(slug):
abort(404)
player_id, is_new = get_or_create_player_id()
node = screens.get_flow_node(slug, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_variable":
return jsonify({"ok": False}), 400
ok = screens.apply_variable_action(slug, node, player_id)
resp = app.make_response(jsonify({"ok": ok}))
if is_new:
set_player_id_cookie(resp, player_id)
return resp
+36
View File
@@ -0,0 +1,36 @@
import json
from flask import render_template, url_for, abort
import db
import screens
from core.flask_app import app
from core.player_identity import get_or_create_player_id, set_player_id_cookie
@app.route("/jouer/<slug>")
def game_play_public(slug):
"""Route PUBLIQUE, non authentifiée (voir core/auth_guard.py) —
distincte de /game/<slug>/play (aperçu créateur, connecté, INCHANGÉE) :
un vrai visiteur anonyme, identifié par un cookie (voir
core/player_identity.py), avec sa propre partie (variables/objets
per_player, voir db/global_vars/, db/rows/). 404 si le créateur n'a
pas explicitement basculé "Publier en ligne" dans son tableau de bord
— jamais exposé par défaut."""
if not db.is_public_played(slug):
abort(404)
game = db.game_meta(slug)
player_id, is_new = get_or_create_player_id()
payload = screens.full_game_payload(slug, player_id)
resp = app.make_response(
render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
runtime_payload_url=url_for("runtime_payload_public", slug=slug),
flow_node_run_data_url=url_for("flow_node_run_data_public", slug=slug, node_id=999999),
flow_node_run_variable_url=url_for("flow_node_run_variable_public", slug=slug, node_id=999999),
)
)
if is_new:
set_player_id_cookie(resp, player_id)
return resp
@@ -0,0 +1,20 @@
from flask import jsonify, abort
import db
import screens
from core.flask_app import app
from core.player_identity import get_or_create_player_id, set_player_id_cookie
@app.route("/jouer/<slug>/runtime-payload")
def runtime_payload_public(slug):
"""Miroir public de routes/flow/runtime_payload.py (aperçu créateur) —
voir routes/public_play/game_play_public.py pour le principe général."""
if not db.is_public_played(slug):
abort(404)
player_id, is_new = get_or_create_player_id()
resp = app.make_response(jsonify(screens.full_game_payload(slug, player_id)))
if is_new:
set_player_id_cookie(resp, player_id)
return resp
+15 -1
View File
@@ -49,8 +49,22 @@
jouable dans un nouvel onglet), pas une page de gestion de plus. -->
<nav class="gameNavBar" aria-label="Navigation du jeu">
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="Écrants, objets, templates, variables">📊 Tableau de bord</a>
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu">▶️ Jouer</a>
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu (une seule partie partagée, pour la conception)">▶️ Jouer</a>
<button type="button" class="gameNavLink" onclick="window.forgeOpenPublishModal && window.forgeOpenPublishModal()" title="Exporte le jeu en exécutable Windows autonome (zip)">📦 Publier</button>
<!-- "Publier en ligne" (état par joueur, Phase 1) : distinct de
"Publier" ci-dessus (export .zip mono-joueur) — active/désactive la
route publique /jouer/<slug>, jouable par n'importe quel visiteur
anonyme avec SA PROPRE partie (voir routes/public_play/,
db/games/is_public_played.py). -->
<form method="post" action="{{ url_for('game_toggle_public_played', slug=game.slug) }}" style="display:inline;">
<input type="hidden" name="value" value="{{ '0' if game.is_public_played else '1' }}">
<button type="submit" class="gameNavLink" title="{{ 'Rendre le jeu privé (désactive /jouer)' if game.is_public_played else 'Rendre le jeu jouable par tous, chacun avec sa propre partie' }}">
{{ '🌐 En ligne ✓' if game.is_public_played else '🌐 Publier en ligne' }}
</button>
</form>
{% if game.is_public_played %}
<a href="{{ url_for('game_play_public', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Ouvre la partie publique, comme un vrai visiteur">🔗 Lien public</a>
{% endif %}
</nav>
{% endif %}
</div>
+10 -4
View File
@@ -79,13 +79,19 @@
// le JS), qui ne peuvent pas contenir de code Jinja puisqu'ils ne
// passent jamais par le moteur de templates. FORGE_PLAY_URLS porte donc
// les URLs de routes dont ces fichiers ont besoin (actions.js,
// bindings.js), construites ici une seule fois.
// bindings.js), construites ici une seule fois — passées par la route
// elle-même (runtime_payload_url/flow_node_run_data_url/
// flow_node_run_variable_url) plutôt que construites ici avec des noms
// de endpoint fixes : ce même template sert aussi bien l'aperçu
// créateur (/game/<slug>/play, routes/play/game_play.py) que la partie
// publique par joueur (/jouer/<slug>, routes/public_play/), deux
// familles de routes distinctes (voir état par joueur, Phase 1).
let gameData = {{ payload_json|safe }};
let screensData = gameData.screens;
var FORGE_PLAY_URLS = {
runtimePayload: {{ url_for('runtime_payload', slug=game.slug)|tojson }},
flowNodeRunData: {{ url_for('flow_node_run_data', slug=game.slug, node_id=999999)|tojson }},
flowNodeRunVariable: {{ url_for('flow_node_run_variable', slug=game.slug, node_id=999999)|tojson }},
runtimePayload: {{ runtime_payload_url|tojson }},
flowNodeRunData: {{ flow_node_run_data_url|tojson }},
flowNodeRunVariable: {{ flow_node_run_variable_url|tojson }},
};
</script>
<script src="{{ url_for('static', filename='js/play/screens.js') }}"></script>
+116
View File
@@ -0,0 +1,116 @@
"""Tests de la route publique /jouer/<slug> (routes/public_play/) — état
par joueur, Phase 1. Contrairement aux autres tests, ceux-ci utilisent
DIRECTEMENT flask_app.test_client() (pas la fixture `client`) pour de
vrais visiteurs anonymes, jamais connectés — le but est justement de
vérifier que core/auth_guard.py laisse passer ces routes sans session."""
import json
import re
import db
from conftest import flask_app
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def _add_flow_node(client, slug, screen_id, **fields):
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(fields),
content_type="application/json",
)
assert resp.status_code == 200
return resp.get_json()["id"]
def test_public_route_404_until_explicitly_published(client, game):
anon = flask_app.test_client()
resp = anon.get(f"/jouer/{game}")
assert resp.status_code == 404
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
resp = anon.get(f"/jouer/{game}")
assert resp.status_code == 200
client.post(f"/game/{game}/toggle-public-played", data={"value": "0"})
resp = anon.get(f"/jouer/{game}")
assert resp.status_code == 404
def test_public_route_works_without_any_login_session(client, game):
"""La garde globale (core/auth_guard.py) ne doit PAS exiger de session
pour cette route — c'est tout le sens de "publique"."""
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
anon = flask_app.test_client()
with anon.session_transaction() as sess:
assert "user_id" not in sess
resp = anon.get(f"/jouer/{game}")
assert resp.status_code == 200
assert b"CSRF_TOKEN" in resp.data or b"gameData" in resp.data
def test_public_route_sets_player_id_cookie(client, game):
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
anon = flask_app.test_client()
resp = anon.get(f"/jouer/{game}")
assert "forge_player_id" in resp.headers.get("Set-Cookie", "")
def test_two_anonymous_players_get_independent_variable_state(client, game):
"""Bout en bout, via HTTP : deux visiteurs anonymes (deux clients de
test SÉPARÉS, donc deux cookies forge_player_id différents) sur le
même jeu publié -> chacun sa propre valeur pour une variable
per_player, modifiée via l'action "Modifier une variable" (le vrai
chemin d'exécution du mode jouable, routes/public_play/
flow_node_run_variable_public.py)."""
db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
screen_id = _create_screen(client, game)
node_id = _add_flow_node(
client, game, screen_id,
node_type="action", action_type="modifier_variable",
target_variable="score", data_operation="incrementer", data_value="10",
)
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
alice = flask_app.test_client()
alice.get(f"/jouer/{game}") # pose le cookie
resp = alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable")
assert resp.get_json()["ok"] is True
bob = flask_app.test_client()
bob.get(f"/jouer/{game}")
resp = bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable")
resp = bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable")
assert resp.get_json()["ok"] is True
assert db.get_global_variable(game, "score", player_id="__shared__")["value"] == "0"
alice_payload = alice.get(f"/jouer/{game}/runtime-payload").get_json()
bob_payload = bob.get(f"/jouer/{game}/runtime-payload").get_json()
assert alice_payload["variables"]["score"]["value"] == "10"
assert bob_payload["variables"]["score"]["value"] == "20"
def test_creator_preview_unaffected_by_public_players(client, game):
"""L'aperçu créateur (/game/<slug>/play, connecté) doit continuer de
voir/modifier PLAYER_SHARED, jamais l'état d'un joueur public."""
db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
screen_id = _create_screen(client, game)
node_id = _add_flow_node(
client, game, screen_id,
node_type="action", action_type="modifier_variable",
target_variable="score", data_operation="incrementer", data_value="10",
)
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
alice = flask_app.test_client()
alice.get(f"/jouer/{game}")
alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable")
resp = client.get(f"/game/{game}/play")
assert resp.status_code == 200
creator_payload = client.get(f"/game/{game}/runtime-payload").get_json()
assert creator_payload["variables"]["score"]["value"] == "0"