"""Environment Jinja2 partagé (A10 : autoescape activé partout). Une seule instance au lieu de 29 `Environment(loader=FileSystemLoader(...))` sans autoescape — 326 interpolations `{{ … }}` étaient servies crues et tous les `|safe` du codebase étaient des no-op. """ from __future__ import annotations import logging import pathlib from contextvars import ContextVar from jinja2 import Environment, FileSystemLoader, Undefined, select_autoescape logger = logging.getLogger(__name__) class LoggingUndefined(Undefined): """Undefined qui se rend comme une chaîne vide mais **journalise**. v7.46.0 : avec l'`Undefined` par défaut, une variable mal orthographiée (ou absente du contexte de la route) rendait `""` sans aucune trace — c'est exactement ce qui a rendu le bug du bouton Home invisible (``local_workspaces`` absent ⇒ `/workspaces`` en silence). ``StrictUndefined`` est inutilisable ici (40+ templates optionnelles, ex. ``embed_mode``), donc on garde le rendu et on rend le silence visible dans les logs. """ __slots__ = () def _warn(self) -> None: logger.warning("Jinja: variable non définie utilisée dans un template: %r", self._undefined_name) def __str__(self) -> str: self._warn() return "" def __bool__(self) -> bool: self._warn() return False def __iter__(self): self._warn() return iter(()) def __len__(self) -> int: self._warn() return 0 # A20 : nonce de script par requête, posé par le middleware CSP, lu par les # templates via `{{ csp_nonce() }}` (vide hors requête — pas de header CSP # dans ce cas, donc rien n'est bloqué). CSP_NONCE: ContextVar[str] = ContextVar("csp_nonce", default="") # A43 : jeton CSRF rendu côté serveur dans `hx-headers` (base.html) — posé # par le middleware CSRF AVANT call_next, lu via `{{ csrf_token() }}` # (vide = cookie absent sur cette requête, htmx:configRequest re-lit le # cookie au moment de l'appel → jamais de « __CSRF_PLACEHOLDER__ » servi). CSRF_TOKEN: ContextVar[str] = ContextVar("csrf_token", default="") ENV = Environment( loader=FileSystemLoader("app/templates"), autoescape=select_autoescape(["html"]), undefined=LoggingUndefined, ) # A40 : version des assets statiques première main, lue une seule fois (le # fichier VERSION fait foi ; repli "dev" si l'image ne le contient pas). try: ASSET_VERSION = (pathlib.Path(__file__).resolve().parent.parent / "VERSION").read_text( encoding="utf-8" ).strip() except OSError: # pragma: no cover ASSET_VERSION = "dev" ENV.globals["asset_version"] = ASSET_VERSION ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get() ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.get()