diff --git a/db/constants.py b/db/constants.py index e9f4739e..4572394c 100644 --- a/db/constants.py +++ b/db/constants.py @@ -2,7 +2,17 @@ import os -PROJECTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects") +# Surchargeable via FORGE_PROJECTS_DIR (même schéma que +# auth/connection.py::FORGE_USERS_DB_PATH) — utilisé par le serveur +# joueur autonome (publish/player_app_template.py), qui pose cette +# variable AVANT d'importer db/ pour pointer vers son propre dossier +# "projects/" embarqué plutôt que celui, réel, du poste de développement. +# Résolu ici, à l'IMPORT (pas par un appel de fonction) : la variable +# d'environnement doit donc déjà être posée avant le tout premier +# `import db`. +PROJECTS_DIR = os.environ.get("FORGE_PROJECTS_DIR") or os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects" +) # Types de champ exposés dans l'interface -> type de colonne SQLite réel. FIELD_TYPES = { diff --git a/publish/__init__.py b/publish/__init__.py new file mode 100644 index 00000000..6be89863 --- /dev/null +++ b/publish/__init__.py @@ -0,0 +1,3 @@ +"""publish — empaquette un jeu en exécutable Windows autonome (zip avec +serveur interne, voir build_package.py) : le bouton "Publier" de la barre +de navigation d'un jeu (templates/base.html).""" diff --git a/publish/build_package.py b/publish/build_package.py new file mode 100644 index 00000000..7ed1ffd0 --- /dev/null +++ b/publish/build_package.py @@ -0,0 +1,113 @@ +"""Assemble le zip exporté d'un jeu (bouton "Publier", voir +routes/publish/publish_game.py) : un dossier autonome contenant Python +portable + Flask (vendorés une fois, voir vendor_runtime.py), une copie +figée du jeu (screens/db/filters/core minimal + templates/play.html + +le sous-ensemble utile de static/), sa base SQLite et ses fichiers +envoyés (uploads/), et les scripts de lancement (player_app.py/run.bat). + +Ne modifie JAMAIS les fichiers réels de l'app (tout se passe dans un +dossier de travail temporaire, supprimé après envoi de la réponse) ni la +vraie base de données du jeu (copiée, jamais déplacée).""" +import os +import shutil +import tempfile +import zipfile + +import db + +from . import vendor_runtime + +_PUBLISH_DIR = os.path.dirname(os.path.abspath(__file__)) +_BASE_DIR = os.path.dirname(_PUBLISH_DIR) + +# Sous-ensemble de static/ réellement nécessaire au mode jouable (voir +# templates/play.html) — jamais les assets propres à l'éditeur seul +# (icônes de l'interface de l'éditeur mises à part, déjà dans icons/). +_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css"] + +_SLUG_PLACEHOLDER = "_SLUG_PLACEHOLDER" + + +def _copy_engine_sources(staging_dir): + """Copie la partie du moteur nécessaire au mode jouable SEUL (jamais + l'éditeur ni l'authentification — voir le constat d'exploration dans + le plan : screens/ et db/ sont déjà totalement découplés de auth// + routes/, core/flask_app.py et core/jinja_filters.py sont les 2 SEULS + fichiers de core/ dont ils dépendent réellement).""" + for pkg in ("screens", "db", "filters"): + shutil.copytree(os.path.join(_BASE_DIR, pkg), os.path.join(staging_dir, pkg), + ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + + core_dir = os.path.join(staging_dir, "core") + os.makedirs(core_dir, exist_ok=True) + for fname in ("__init__.py", "flask_app.py", "jinja_filters.py"): + shutil.copy2(os.path.join(_BASE_DIR, "core", fname), os.path.join(core_dir, fname)) + + templates_dir = os.path.join(staging_dir, "templates") + os.makedirs(templates_dir, exist_ok=True) + shutil.copy2(os.path.join(_BASE_DIR, "templates", "play.html"), os.path.join(templates_dir, "play.html")) + + static_dir = os.path.join(staging_dir, "static") + for item in _STATIC_ITEMS: + src = os.path.join(_BASE_DIR, "static", item) + dst = os.path.join(static_dir, item) + os.makedirs(os.path.dirname(dst), exist_ok=True) + if os.path.isdir(src): + shutil.copytree(src, dst) + else: + shutil.copy2(src, dst) + + +def _copy_game_data(staging_dir, slug): + """Copie le dossier RÉEL du jeu (game.db + uploads/, voir + db.game_dir) tel quel — jamais déplacé, jamais modifié.""" + src = db.game_dir(slug) + dst = os.path.join(staging_dir, "projects", slug) + shutil.copytree(src, dst, ignore=shutil.ignore_patterns("index.html", "index.css", "index.js")) + + +def _write_player_app(staging_dir, slug): + template_path = os.path.join(_PUBLISH_DIR, "player_app_template.py") + with open(template_path, encoding="utf-8") as f: + content = f.read() + content = content.replace(_SLUG_PLACEHOLDER, slug) + with open(os.path.join(staging_dir, "player_app.py"), "w", encoding="utf-8") as f: + f.write(content) + + +def _write_run_bat(staging_dir): + src = os.path.join(_PUBLISH_DIR, "run_bat_template.bat") + shutil.copy2(src, os.path.join(staging_dir, "run.bat")) + + +def _zip_dir(staging_dir, zip_path): + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for root, _dirs, files in os.walk(staging_dir): + for name in files: + full = os.path.join(root, name) + rel = os.path.relpath(full, staging_dir) + zf.write(full, rel) + + +def build_game_zip(slug): + """Construit le zip et renvoie son chemin (dans un dossier temporaire + — à l'appelant de le supprimer une fois la réponse HTTP envoyée, voir + routes/publish/publish_game.py).""" + game = db.game_meta(slug) + python_dir, pylibs_dir = vendor_runtime.ensure_vendor_ready() + + staging_dir = tempfile.mkdtemp(prefix="forge_publish_") + try: + shutil.copytree(python_dir, os.path.join(staging_dir, "python-embed")) + shutil.copytree(pylibs_dir, os.path.join(staging_dir, "python-embed", "pylibs")) + _copy_engine_sources(staging_dir) + _copy_game_data(staging_dir, slug) + _write_player_app(staging_dir, slug) + _write_run_bat(staging_dir) + + zip_fd, zip_path = tempfile.mkstemp(prefix="forge_publish_", suffix=".zip") + os.close(zip_fd) + _zip_dir(staging_dir, zip_path) + finally: + shutil.rmtree(staging_dir, ignore_errors=True) + return zip_path, game["name"] diff --git a/publish/player_app_template.py b/publish/player_app_template.py new file mode 100644 index 00000000..c4e976dc --- /dev/null +++ b/publish/player_app_template.py @@ -0,0 +1,107 @@ +"""player_app.py — serveur autonome généré par Forge Engine pour UN SEUL +jeu (voir publish/build_package.py) : aucune installation externe requise, +lancé par double-clic sur run.bat (Python portable + Flask embarqués à +côté de ce fichier). Reprend telles quelles les routes utilisées par le +mode jouable (voir templates/play.html) — jamais l'éditeur ni +l'authentification, absents de cet export. + +SLUG est remplacé littéralement par build_package.py au moment de la +publication (voir _SLUG_PLACEHOLDER) — ce fichier n'est jamais exécuté +tel quel depuis le dépôt source.""" +import json +import os +import socket +import sys +import threading +import webbrowser + +_BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Le python311._pth de Python embeddable (voir publish/vendor_runtime.py) +# ne met sur sys.path QUE le dossier de python.exe lui-même (python-embed/) +# et "pylibs" — jamais le dossier de CE script, qui vit un cran au-dessus +# (sibling de python-embed/). Sans cette ligne, "import core"/"db"/ +# "screens" échoue avec ModuleNotFoundError bien qu'ils soient juste à +# côté de ce fichier. +sys.path.insert(0, _BASE_DIR) + +# Doit être posé AVANT tout import de db/ : db/constants.py lit +# FORGE_PROJECTS_DIR à l'IMPORT, pas à l'appel — pointe ici vers le +# dossier "projects/" embarqué à côté de ce fichier, jamais un chemin du +# poste où le jeu a été publié. +os.environ["FORGE_PROJECTS_DIR"] = os.path.join(_BASE_DIR, "projects") + +from flask import jsonify, redirect, render_template, request, send_from_directory, url_for # noqa: E402 + +from core.flask_app import app # noqa: E402 +from core import jinja_filters # noqa: E402,F401 - enregistre les filtres Jinja (elstyle/eltransform/elabel/colname) +import db # noqa: E402 +import screens # noqa: E402 + +# Pas de CSRF/auth dans cet export (un seul jeu, aucune connexion) — +# play.html appelle quand même csrf_token() en Jinja (voir base.html/ +# play.html) : un no-op le laisse fonctionner tel quel, sans le forker. +app.jinja_env.globals["csrf_token"] = lambda: "" + +SLUG = "_SLUG_PLACEHOLDER" + + +@app.route("/") +def index(): + return redirect(url_for("game_play", slug=SLUG)) + + +@app.route("/game//play") +def game_play(slug): + 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), + ) + + +@app.route("/game//runtime-payload") +def runtime_payload(slug): + return jsonify(screens.full_game_payload(SLUG)) + + +@app.route("/game//flow/nodes//run-data", methods=["POST"]) +def flow_node_run_data(slug, node_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) + return jsonify({"ok": ok}) + + +@app.route("/game//flow/nodes//run-variable", methods=["POST"]) +def flow_node_run_variable(slug, node_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) + return jsonify({"ok": ok}) + + +@app.route("/game//uploads/") +def uploaded_file(slug, filename): + return send_from_directory(os.path.join(db.game_dir(SLUG), "uploads"), filename) + + +def _open_browser(port): + webbrowser.open(f"http://127.0.0.1:{port}/") + + +if __name__ == "__main__": + # Port libre choisi au hasard par l'OS (jamais le même serveur à + # relancer sur un port déjà pris par une partie précédente encore + # ouverte) — le navigateur est ouvert automatiquement, l'utilisateur + # n'a jamais besoin de connaître ni taper le port. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + threading.Timer(1.0, _open_browser, args=(port,)).start() + app.run(host="127.0.0.1", port=port, debug=False, use_reloader=False) diff --git a/publish/run_bat_template.bat b/publish/run_bat_template.bat new file mode 100644 index 00000000..660f8d43 --- /dev/null +++ b/publish/run_bat_template.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "%~dp0" +python-embed\python.exe player_app.py +pause diff --git a/publish/vendor_runtime.py b/publish/vendor_runtime.py new file mode 100644 index 00000000..92824afa --- /dev/null +++ b/publish/vendor_runtime.py @@ -0,0 +1,91 @@ +"""Prépare, une seule fois par poste, tout ce qu'il faut pour empaqueter +un jeu en exécutable autonome : Python portable (embeddable, python.org) +et les paquets pip nécessaires (Flask + ses dépendances), mis en cache +localement sous data/publish_vendor/ (non commité, voir .gitignore — +même dossier que data/secret_key/data/users.db, déjà runtime-only). + +La toute PREMIÈRE publication d'un jeu sur un poste donné télécharge ces +~15 Mo (connexion internet nécessaire, une seule fois) ; toutes les +publications suivantes réutilisent le cache, hors-ligne. Le JOUEUR qui +reçoit le zip final, lui, n'a jamais besoin d'internet — ce cache ne +concerne que la machine qui PUBLIE. + +Fonctions séparées et mockables (download_python_embeddable/ +vendor_flask) pour que les tests ne déclenchent jamais un vrai +téléchargement.""" +import os +import subprocess +import sys +import urllib.request +import zipfile + +_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +VENDOR_DIR = os.environ.get("FORGE_PUBLISH_VENDOR_DIR") or os.path.join(_BASE_DIR, "data", "publish_vendor") + +PYTHON_VERSION = "3.11.9" +PYTHON_EMBED_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" +PYTHON_EMBED_DIR = os.path.join(VENDOR_DIR, "python-embed-amd64") +PYLIBS_DIR = os.path.join(VENDOR_DIR, "pylibs") + +# Mêmes versions que requirements.txt — seul Flask est listé explicitement, +# ses dépendances (Werkzeug/Jinja2/MarkupSafe/click/itsdangerous/blinker) +# suivent automatiquement via pip. +FLASK_SPEC = "Flask==3.0.3" + + +def download_python_embeddable(): + """Télécharge et extrait le ZIP Python embeddable officiel si absent + du cache — no-op si déjà présent (idempotent, sûr à rappeler à + chaque publication).""" + marker = os.path.join(PYTHON_EMBED_DIR, "python.exe") + if os.path.isfile(marker): + return PYTHON_EMBED_DIR + os.makedirs(PYTHON_EMBED_DIR, exist_ok=True) + zip_path = os.path.join(VENDOR_DIR, "python-embed-amd64.zip") + urllib.request.urlretrieve(PYTHON_EMBED_URL, zip_path) + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(PYTHON_EMBED_DIR) + os.remove(zip_path) + _enable_pylibs_path(PYTHON_EMBED_DIR) + return PYTHON_EMBED_DIR + + +def _enable_pylibs_path(python_embed_dir): + """Le ZIP embeddable désactive site-packages par défaut + (python311._pth ne contient que "python311.zip" et "."). On ajoute une + ligne "pylibs" (chemin relatif à python.exe) plutôt que de réactiver + tout le mécanisme "site" — les paquets vendorés (Flask...) sont de + simples dossiers purement Python, aucun besoin d'aller plus loin.""" + pth_files = [f for f in os.listdir(python_embed_dir) if f.endswith("._pth")] + if not pth_files: + return + pth_path = os.path.join(python_embed_dir, pth_files[0]) + with open(pth_path, encoding="utf-8") as f: + content = f.read() + if "pylibs" not in content: + with open(pth_path, "a", encoding="utf-8") as f: + f.write("\npylibs\n") + + +def vendor_flask(): + """pip install --target=... : installe Flask (paquets purs Python, + indépendants de la version exacte de l'interpréteur embeddable) dans + le cache, réutilisé par tous les exports suivants — no-op si déjà + fait.""" + if os.path.isdir(PYLIBS_DIR) and os.listdir(PYLIBS_DIR): + return PYLIBS_DIR + os.makedirs(PYLIBS_DIR, exist_ok=True) + subprocess.run( + [sys.executable, "-m", "pip", "install", "--target", PYLIBS_DIR, "--no-compile", FLASK_SPEC], + check=True, + ) + return PYLIBS_DIR + + +def ensure_vendor_ready(): + """Point d'entrée unique appelé par build_package.py — télécharge/ + installe ce qui manque, puis renvoie (dossier_python_embed, + dossier_pylibs).""" + python_dir = download_python_embeddable() + pylibs_dir = vendor_flask() + return python_dir, pylibs_dir diff --git a/routes/__init__.py b/routes/__init__.py index 995d16be..722a00c0 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 +from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish diff --git a/routes/publish/__init__.py b/routes/publish/__init__.py new file mode 100644 index 00000000..4961124c --- /dev/null +++ b/routes/publish/__init__.py @@ -0,0 +1 @@ +from . import publish_game diff --git a/routes/publish/publish_game.py b/routes/publish/publish_game.py new file mode 100644 index 00000000..7cd043af --- /dev/null +++ b/routes/publish/publish_game.py @@ -0,0 +1,28 @@ +import os + +from flask import after_this_request, send_file + +import db +from publish.build_package import build_game_zip + +from core.flask_app import app + + +@app.route("/game//publish", methods=["POST"]) +def publish_game(slug): + """Construit et renvoie le zip exécutable autonome du jeu (voir + publish/build_package.py) — protégée par la même garde d'accès que + toutes les autres routes /game//... (core/auth_guard.py), + aucune vérification supplémentaire nécessaire ici.""" + zip_path, game_name = build_game_zip(slug) + + @after_this_request + def _cleanup(response): + try: + os.remove(zip_path) + except OSError: + pass + return response + + download_name = f"{db.slugify(game_name)}.zip" + return send_file(zip_path, as_attachment=True, download_name=download_name, mimetype="application/zip") diff --git a/templates/base.html b/templates/base.html index f75daacb..53f8de58 100644 --- a/templates/base.html +++ b/templates/base.html @@ -50,6 +50,7 @@ {% endif %} @@ -78,6 +79,94 @@ +{% endif %} +{% if game is defined and game %} + + + + {% endif %} {% block content %}{% endblock %} diff --git a/tests/test_publish.py b/tests/test_publish.py new file mode 100644 index 00000000..a7a01801 --- /dev/null +++ b/tests/test_publish.py @@ -0,0 +1,91 @@ +"""Publier un jeu en exécutable Windows autonome (bouton "Publier", voir +routes/publish/publish_game.py et publish/build_package.py). Le vrai +téléchargement du Python portable + Flask (publish/vendor_runtime.py) +n'a JAMAIS lieu ici : `ensure_vendor_ready` est monkeypatché vers un +dossier factice minuscule — ces tests vérifient l'ASSEMBLAGE du zip +(fichiers présents, structure), jamais un vrai lancement du serveur +embarqué (nécessite un environnement Windows réel, voir le plan).""" +import os +import zipfile + +import pytest + +import publish.build_package as build_package + +from tests.test_auth import anon_client # noqa: F401 + + +@pytest.fixture +def fake_vendor(tmp_path, monkeypatch): + """Un python-embed/pylibs factice, juste assez pour que l'assemblage + du zip (copie de fichiers) fonctionne sans jamais toucher au réseau.""" + python_dir = tmp_path / "python-embed-amd64" + python_dir.mkdir() + (python_dir / "python.exe").write_bytes(b"fake-exe") + pylibs_dir = tmp_path / "pylibs" + pylibs_dir.mkdir() + (pylibs_dir / "flask").mkdir() + (pylibs_dir / "flask" / "__init__.py").write_text("# fake", encoding="utf-8") + + def _fake_ensure_vendor_ready(): + return str(python_dir), str(pylibs_dir) + + monkeypatch.setattr(build_package.vendor_runtime, "ensure_vendor_ready", _fake_ensure_vendor_ready) + return python_dir, pylibs_dir + + +def test_build_game_zip_contains_the_expected_files(fake_vendor, game): + zip_path, game_name = build_package.build_game_zip(game) + try: + assert game_name # nom du jeu (pytest_test_game par défaut, voir conftest.py) + with zipfile.ZipFile(zip_path) as zf: + names = set(zf.namelist()) + for expected in [ + "run.bat", "player_app.py", + f"projects/{game}/game.db", + "screens/__init__.py", "db/__init__.py", "filters/__init__.py", + "core/__init__.py", "core/flask_app.py", "core/jinja_filters.py", + "templates/play.html", + "static/style.css", "static/csrf_fetch.js", + "python-embed/python.exe", "python-embed/pylibs/flask/__init__.py", + ]: + assert expected in names, f"{expected} manquant du zip" + finally: + if os.path.isfile(zip_path): + os.remove(zip_path) + + +def test_build_game_zip_bakes_the_slug_into_player_app(fake_vendor, game): + zip_path, _ = build_package.build_game_zip(game) + try: + with zipfile.ZipFile(zip_path) as zf: + player_app_src = zf.read("player_app.py").decode("utf-8") + assert f'SLUG = "{game}"' in player_app_src + assert "_SLUG_PLACEHOLDER" not in player_app_src + finally: + if os.path.isfile(zip_path): + os.remove(zip_path) + + +def test_publish_route_returns_a_zip_attachment(fake_vendor, client, game): + resp = client.post(f"/game/{game}/publish") + assert resp.status_code == 200 + assert resp.headers["Content-Type"] == "application/zip" + assert "attachment" in resp.headers["Content-Disposition"] + assert resp.data[:2] == b"PK" # signature d'un fichier zip + + +def test_publish_route_is_isolated_like_other_game_routes(fake_vendor, anon_client): + """Réutilise la garde d'accès existante (core/auth_guard.py) sans + code supplémentaire — un compte non-admin ne doit pas pouvoir publier + le projet d'un autre (même vérification que /game/ lui-même, + voir tests/test_auth.py::test_non_admin_user_is_isolated_to_their_own_project).""" + from tests.test_auth import _register, _confirm_2fa, _cleanup_project + + _register(anon_client, "publishisolation@example.com") + _confirm_2fa(anon_client) + try: + resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/publish") + assert resp.status_code == 403 + finally: + _cleanup_project("publishisolation@example.com")