diff --git a/core/auth_guard.py b/core/auth_guard.py index 949629c2..126f1b11 100644 --- a/core/auth_guard.py +++ b/core/auth_guard.py @@ -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/, 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", } diff --git a/core/player_identity.py b/core/player_identity.py new file mode 100644 index 00000000..d1639c71 --- /dev/null +++ b/core/player_identity.py @@ -0,0 +1,35 @@ +"""Identité d'un visiteur anonyme de la route publique /jouer/ (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/) 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 diff --git a/db/__init__.py b/db/__init__.py index a1d375c5..41be883d 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -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", diff --git a/db/games/game_meta.py b/db/games/game_meta.py index 3d806029..ab6de535 100644 --- a/db/games/game_meta.py +++ b/db/games/game_meta.py @@ -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), + } diff --git a/db/games/is_public_played.py b/db/games/is_public_played.py new file mode 100644 index 00000000..32064302 --- /dev/null +++ b/db/games/is_public_played.py @@ -0,0 +1,14 @@ +from ..connection import connect + + +def is_public_played(slug): + """Un jeu n'est jouable sur la route publique /jouer/ (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") diff --git a/db/games/set_public_played.py b/db/games/set_public_played.py new file mode 100644 index 00000000..2842e47f --- /dev/null +++ b/db/games/set_public_played.py @@ -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() diff --git a/routes/__init__.py b/routes/__init__.py index 2e9d945f..eab6058d 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -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 diff --git a/routes/games/__init__.py b/routes/games/__init__.py index 471a48c9..bfc0d253 100644 --- a/routes/games/__init__.py +++ b/routes/games/__init__.py @@ -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 diff --git a/routes/games/game_toggle_public_played.py b/routes/games/game_toggle_public_played.py new file mode 100644 index 00000000..a50e7d5b --- /dev/null +++ b/routes/games/game_toggle_public_played.py @@ -0,0 +1,16 @@ +from flask import request, redirect, url_for + +import db + +from core.flask_app import app + + +@app.route("/game//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/ (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)) diff --git a/routes/play/game_play.py b/routes/play/game_play.py index 98c73c30..e677703e 100644 --- a/routes/play/game_play.py +++ b/routes/play/game_play.py @@ -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//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/ + (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), ) diff --git a/routes/public_play/__init__.py b/routes/public_play/__init__.py new file mode 100644 index 00000000..8d21da09 --- /dev/null +++ b/routes/public_play/__init__.py @@ -0,0 +1 @@ +from . import game_play_public, runtime_payload_public, flow_node_run_data_public, flow_node_run_variable_public diff --git a/routes/public_play/flow_node_run_data_public.py b/routes/public_play/flow_node_run_data_public.py new file mode 100644 index 00000000..08576fd9 --- /dev/null +++ b/routes/public_play/flow_node_run_data_public.py @@ -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//flow/nodes//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 diff --git a/routes/public_play/flow_node_run_variable_public.py b/routes/public_play/flow_node_run_variable_public.py new file mode 100644 index 00000000..cb0ad632 --- /dev/null +++ b/routes/public_play/flow_node_run_variable_public.py @@ -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//flow/nodes//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 diff --git a/routes/public_play/game_play_public.py b/routes/public_play/game_play_public.py new file mode 100644 index 00000000..6381a5e1 --- /dev/null +++ b/routes/public_play/game_play_public.py @@ -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/") +def game_play_public(slug): + """Route PUBLIQUE, non authentifiée (voir core/auth_guard.py) — + distincte de /game//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 diff --git a/routes/public_play/runtime_payload_public.py b/routes/public_play/runtime_payload_public.py new file mode 100644 index 00000000..867706ea --- /dev/null +++ b/routes/public_play/runtime_payload_public.py @@ -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//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 diff --git a/templates/base.html b/templates/base.html index 53f8de58..2beab4f6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -49,8 +49,22 @@ jouable dans un nouvel onglet), pas une page de gestion de plus. --> {% endif %} diff --git a/templates/play.html b/templates/play.html index f1c2427a..15c2161c 100644 --- a/templates/play.html +++ b/templates/play.html @@ -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//play, routes/play/game_play.py) que la partie + // publique par joueur (/jouer/, 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 }}, }; diff --git a/tests/test_public_play.py b/tests/test_public_play.py new file mode 100644 index 00000000..2ebcec8b --- /dev/null +++ b/tests/test_public_play.py @@ -0,0 +1,116 @@ +"""Tests de la route publique /jouer/ (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//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"