feat: URLs publiques de l'écosystème par configuration — dev/prod sans noms en dur #223
This commit is contained in:
@@ -55,6 +55,9 @@ import httpx
|
||||
logger = logging.getLogger("obsigate.agent_journal")
|
||||
|
||||
SOURCE_APP = "obsigate"
|
||||
# Défaut officiel, conservé pour référence : depuis #223, ``_url()``
|
||||
# passe par la config centralisée (``backend.draco_config``) — jamais
|
||||
# un nom d'environnement particulier.
|
||||
URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements"
|
||||
DELAI_S = 2.0
|
||||
|
||||
@@ -72,7 +75,15 @@ def _cle() -> str:
|
||||
|
||||
|
||||
def _url() -> str:
|
||||
return os.environ.get("AGENT_EVENEMENTS_URL", "").strip() or URL_DEFAUT
|
||||
# #223 : AGENT_EVENEMENTS_URL d'abord ; à défaut, l'URL du service
|
||||
# configurée (DRACO_AGENT_URL) complétée du chemin d'événements ;
|
||||
# à défaut, le défaut officiel.
|
||||
explicite = os.environ.get("AGENT_EVENEMENTS_URL", "").strip()
|
||||
if explicite:
|
||||
return explicite
|
||||
from backend.draco_config import agent_url
|
||||
|
||||
return f"{agent_url()}/api/v1/evenements"
|
||||
|
||||
|
||||
def actif() -> bool:
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""URLs publiques de l'écosystème dracodev — par configuration (#223).
|
||||
|
||||
Règle dev/prod (Bruno, 2026-10-25) : **aucun nom public de
|
||||
l'écosystème n'est figé dans le code vivant**. Chaque URL publique
|
||||
vient de l'environnement ; les valeurs par défaut sont les noms
|
||||
**officiels** (sans suffixe) de la future production. L'environnement
|
||||
dev déclare ses noms en ``-dev`` dans son seul ``.env`` — le code
|
||||
n'en connaît aucun.
|
||||
|
||||
Variables (voir ``.env.example``) :
|
||||
|
||||
- ``DRACO_AGENT_URL`` — base publique du service d'agents ;
|
||||
à défaut, l'origine est **dérivée** de ``AGENT_EVENEMENTS_URL``
|
||||
(la configuration réelle de l'émission d'événements, #220), puis
|
||||
le défaut officiel.
|
||||
- ``DRACO_OBSIGATE_URL`` — base publique d'ObsiGate (liens profonds).
|
||||
- ``DRACO_FLOWDECK_URL`` — base publique de Flowdeck.
|
||||
- ``DRACO_TIKTAK_URL`` — base publique de TikTak.
|
||||
- ``DRACO_KANBANTE_URL`` — base publique de Kanbante.
|
||||
- ``DRACO_GITEA_URL`` — base publique de Gitea (les cartes
|
||||
Kanbante *sont* des issues Gitea, contrat liens-profonds) ;
|
||||
distincte de ``OBSIGATE_GITEA_URL`` (source connectée de
|
||||
l'assistant, réglable depuis l'UI, pas une URL d'affichage).
|
||||
|
||||
Ce module ne lit l'environnement qu'à l'appel (jamais à l'import) :
|
||||
les tests et un changement de ``.env`` suivi d'un redémarrage
|
||||
prennent effet sans état figé.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Défauts officiels (production future) — jamais de nom en ``-dev`` ici.
|
||||
DEFAUTS: dict[str, str] = {
|
||||
"agent": "https://agent.dracodev.net",
|
||||
"obsigate": "https://og.dracodev.net",
|
||||
"flowdeck": "https://flowdeck.dracodev.net",
|
||||
"tiktak": "https://tiktak.dracodev.net",
|
||||
"kanbante": "https://kanbante.dracodev.net",
|
||||
"gitea": "https://git.dracodev.net",
|
||||
}
|
||||
|
||||
_VARIABLES: dict[str, str] = {
|
||||
"agent": "DRACO_AGENT_URL",
|
||||
"obsigate": "DRACO_OBSIGATE_URL",
|
||||
"flowdeck": "DRACO_FLOWDECK_URL",
|
||||
"tiktak": "DRACO_TIKTAK_URL",
|
||||
"kanbante": "DRACO_KANBANTE_URL",
|
||||
"gitea": "DRACO_GITEA_URL",
|
||||
}
|
||||
|
||||
|
||||
def _nettoyer(valeur: str) -> str:
|
||||
return valeur.strip().rstrip("/")
|
||||
|
||||
|
||||
def origine(url: str) -> str:
|
||||
"""Origine ``schéma://hôte[:port]`` d'une URL (``""`` si illisible)."""
|
||||
u = urlparse(url or "")
|
||||
if u.scheme and u.netloc:
|
||||
return f"{u.scheme}://{u.netloc}"
|
||||
return ""
|
||||
|
||||
|
||||
def agent_url() -> str:
|
||||
"""Base publique du service d'agents.
|
||||
|
||||
Priorité : ``DRACO_AGENT_URL``, puis l'origine de
|
||||
``AGENT_EVENEMENTS_URL`` (déjà la configuration réelle du poste),
|
||||
puis le défaut officiel.
|
||||
"""
|
||||
explicite = _nettoyer(os.environ.get(_VARIABLES["agent"], ""))
|
||||
if explicite:
|
||||
return explicite
|
||||
evenements = _nettoyer(os.environ.get("AGENT_EVENEMENTS_URL", ""))
|
||||
if evenements:
|
||||
derivee = origine(evenements)
|
||||
if derivee:
|
||||
return derivee
|
||||
return DEFAUTS["agent"]
|
||||
|
||||
|
||||
def agent_origine() -> str:
|
||||
"""Origine du service d'agents, pour la CSP (``connect-src``/``frame-src``)."""
|
||||
return origine(agent_url()) or DEFAUTS["agent"]
|
||||
|
||||
|
||||
def app_url(app: str) -> str:
|
||||
"""Base publique d'une application de l'écosystème (sans ``/`` final)."""
|
||||
if app == "agent":
|
||||
return agent_url()
|
||||
variable = _VARIABLES[app]
|
||||
return _nettoyer(os.environ.get(variable, "")) or DEFAUTS[app]
|
||||
|
||||
|
||||
def app_hote(app: str) -> str:
|
||||
"""Nom d'hôte (minuscules) de l'URL publique configurée de *app*."""
|
||||
return (urlparse(app_url(app)).hostname or "").lower()
|
||||
|
||||
|
||||
def hotes_reconnus() -> dict[str, str]:
|
||||
"""``{hôte: app}`` pour le parseur de liens profonds.
|
||||
|
||||
Contient **toujours** les hôtes officiels (un lien collé depuis la
|
||||
production reste reconnaissable en dev, et réciproquement) **plus**
|
||||
l'hôte configuré de chaque application (les noms en ``-dev`` du
|
||||
poste quand ils sont déclarés dans son ``.env``).
|
||||
"""
|
||||
hotes: dict[str, str] = {}
|
||||
for app, defaut in DEFAUTS.items():
|
||||
hote = (urlparse(defaut).hostname or "").lower()
|
||||
if hote:
|
||||
hotes[hote] = app
|
||||
for app in DEFAUTS:
|
||||
hote = app_hote(app)
|
||||
if hote:
|
||||
hotes[hote] = app
|
||||
return hotes
|
||||
|
||||
|
||||
def urls_publiques() -> dict[str, str]:
|
||||
"""Carte des URL publiques injectée à l'hôte web (``window.DRACO_URLS``).
|
||||
|
||||
C'est le contrat d'injection de dracodev-commons : l'application
|
||||
hôte (ici ObsiGate, qui sert ``index.html``) fournit les URL aux
|
||||
assets partagés (panneau Spark, barre, liens) — les assets ne
|
||||
portent que les défauts officiels en repli.
|
||||
"""
|
||||
return {app: app_url(app) for app in DEFAUTS}
|
||||
+76
-10
@@ -24,7 +24,7 @@ import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
from urllib.parse import parse_qs, quote, unquote, urlparse
|
||||
|
||||
logger = logging.getLogger("obsigate.external_refs")
|
||||
|
||||
@@ -64,26 +64,87 @@ def _write_refs(username: str, refs: list[dict[str, Any]]) -> None:
|
||||
|
||||
# ------------------------------------------------------------ parseur d'URL
|
||||
|
||||
def build_deep_link(target_app: str, target_type: str, target_id: str) -> str | None:
|
||||
"""Construit l'URL profonde d'affichage depuis la **configuration**
|
||||
courante (#223), ou ``None`` si la forme est inconnue.
|
||||
|
||||
Les références stockées portent une URL absolue figée au moment du
|
||||
collage (voir ``add_ref``) : elle peut nommer l'autre environnement
|
||||
(un lien collé en prod, lu en dev). L'affichage ne l'utilise donc
|
||||
jamais telle quelle — il reconstruit l'URL depuis
|
||||
``(target_app, target_type, target_id)`` et les bases publiques
|
||||
configurées (``backend.draco_config``), au moment de la lecture.
|
||||
Aucune donnée stockée n'est migrée ni réécrite.
|
||||
"""
|
||||
from backend.draco_config import app_url
|
||||
|
||||
if target_app == "tiktak":
|
||||
if target_type == "task":
|
||||
return f"{app_url('tiktak')}/?task={quote(target_id, safe='')}"
|
||||
if target_type == "list":
|
||||
return f"{app_url('tiktak')}/?list={quote(target_id, safe='')}"
|
||||
return None
|
||||
if target_app == "obsigate" and target_type == "note":
|
||||
return f"{app_url('obsigate')}/#file={quote(target_id, safe=':')}"
|
||||
if target_app == "flowdeck":
|
||||
if target_type == "page":
|
||||
return f"{app_url('flowdeck')}/pages/{quote(target_id, safe='')}"
|
||||
if target_type == "project":
|
||||
return f"{app_url('flowdeck')}/collections/{quote(target_id, safe='')}"
|
||||
return None
|
||||
if target_app == "kanbante" and target_type == "card":
|
||||
m = re.fullmatch(r"([^/]+)/([^/#]+)#(\d+)", target_id or "")
|
||||
if m:
|
||||
owner, repo, num = m.groups()
|
||||
return f"{app_url('gitea')}/{owner}/{repo}/issues/{num}"
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _avec_url_affichee(ref: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Copie de la référence dont ``target_url`` est reconstruite
|
||||
depuis la config courante (#223) — le stockage n'est pas touché."""
|
||||
construite = build_deep_link(
|
||||
str(ref.get("target_app") or ""),
|
||||
str(ref.get("target_type") or ""),
|
||||
str(ref.get("target_id") or ""),
|
||||
)
|
||||
if construite is None:
|
||||
return ref
|
||||
copie = dict(ref)
|
||||
copie["target_url"] = construite
|
||||
return copie
|
||||
|
||||
|
||||
def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
|
||||
"""Reconnaît une URL profonde des applications dracodev.
|
||||
|
||||
Formes figées par ``dracodev-commons/contrats/liens-profonds.md``.
|
||||
Retourne ``(target_app, target_type, target_id, label_par_défaut)``
|
||||
ou ``None`` si l'URL ne correspond à aucune forme du contrat.
|
||||
|
||||
Hôtes reconnus (#223) : les hôtes **officiels** et les hôtes
|
||||
**configurés** de chaque application (``backend.draco_config``) —
|
||||
un lien collé depuis l'environnement courant (noms en ``-dev``
|
||||
déclarés dans son ``.env``) comme depuis l'autre environnement
|
||||
est donc reconnu ; aucun nom d'environnement n'est figé ici.
|
||||
"""
|
||||
from backend.draco_config import hotes_reconnus
|
||||
|
||||
try:
|
||||
u = urlparse((url or "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
host = (u.hostname or "").lower()
|
||||
if host == "tiktak.dracodev.net":
|
||||
app = hotes_reconnus().get(host)
|
||||
if app == "tiktak":
|
||||
q = parse_qs(u.query)
|
||||
if q.get("task"):
|
||||
return ("tiktak", "task", q["task"][0], f"TikTak · tâche {q['task'][0]}")
|
||||
if q.get("list"):
|
||||
return ("tiktak", "list", q["list"][0], f"TikTak · liste {q['list'][0]}")
|
||||
return None
|
||||
if host == "og.dracodev.net":
|
||||
if app == "obsigate":
|
||||
frag = u.fragment or ""
|
||||
if frag.startswith("file="):
|
||||
cible = unquote(frag[5:])
|
||||
@@ -91,7 +152,7 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
|
||||
nom = cible.rsplit("/", 1)[-1]
|
||||
return ("obsigate", "note", cible, f"ObsiGate · {nom}")
|
||||
return None
|
||||
if host == "flowdeck.dracodev.net":
|
||||
if app == "flowdeck":
|
||||
m = re.fullmatch(r"/pages/(\d+)", u.path or "")
|
||||
if m:
|
||||
return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}")
|
||||
@@ -99,7 +160,7 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
|
||||
if m:
|
||||
return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}")
|
||||
return None
|
||||
if host == "git.dracodev.net":
|
||||
if app == "gitea":
|
||||
m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "")
|
||||
if m:
|
||||
owner, repo, num = m.groups()
|
||||
@@ -111,20 +172,25 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
|
||||
# ------------------------------------------------------------ CRUD
|
||||
|
||||
def list_refs(username: str, vault: str, path: str) -> list[dict[str, Any]]:
|
||||
"""Références posées sur une note, dans l'ordre de création."""
|
||||
"""Références posées sur une note, dans l'ordre de création.
|
||||
|
||||
``target_url`` de chaque référence retournée est **reconstruite
|
||||
depuis la config courante** (#223, ``_avec_url_affichee``) : la
|
||||
valeur stockée, figée au collage, n'est jamais servie telle quelle.
|
||||
"""
|
||||
if not username:
|
||||
return []
|
||||
return [
|
||||
r for r in _read_refs(username)
|
||||
_avec_url_affichee(r) for r in _read_refs(username)
|
||||
if r.get("owner_vault") == vault and r.get("owner_path") == path
|
||||
]
|
||||
|
||||
|
||||
def get_ref(username: str, ref_id: str) -> dict[str, Any] | None:
|
||||
"""Une référence par son identifiant, ou ``None``."""
|
||||
"""Une référence par son identifiant, ou ``None`` (URL reconstruite, #223)."""
|
||||
for r in _read_refs(username):
|
||||
if r.get("id") == ref_id:
|
||||
return r
|
||||
return _avec_url_affichee(r)
|
||||
return None
|
||||
|
||||
|
||||
@@ -169,4 +235,4 @@ def delete_ref(username: str, ref_id: str) -> dict[str, Any] | None:
|
||||
return None
|
||||
removed = next(r for r in refs if r.get("id") == ref_id)
|
||||
_write_refs(username, kept)
|
||||
return removed
|
||||
return _avec_url_affichee(removed)
|
||||
|
||||
+39
-3
@@ -186,15 +186,21 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
# avec un nonce frais (`backend.csp`) ou servis par 'self'/CDN
|
||||
# listés s'exécutent. `style-src` garde 'unsafe-inline' (attributs
|
||||
# `style=` et `el.style` omniprésents — chantier séparé).
|
||||
# #223 : l'origine du service d'agents n'est pas figée — elle
|
||||
# est dérivée de la configuration (DRACO_AGENT_URL, sinon
|
||||
# l'origine d'AGENT_EVENEMENTS_URL, sinon le défaut officiel).
|
||||
from backend.draco_config import agent_origine
|
||||
|
||||
agent = agent_origine()
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://agent.dracodev.net; "
|
||||
f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent}; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
|
||||
"worker-src 'self' blob:; "
|
||||
"frame-src 'self' blob: https://agent.dracodev.net; "
|
||||
f"frame-src 'self' blob: {agent}; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'; "
|
||||
@@ -829,6 +835,36 @@ def _html_with_nonce(request: Request, name: str) -> str:
|
||||
request.state.csp_nonce,
|
||||
)
|
||||
|
||||
|
||||
def _index_html(request: Request) -> str:
|
||||
"""``index.html`` + injection des URL publiques de l'écosystème (#223).
|
||||
|
||||
Contrat d'injection de dracodev-commons : l'hôte (ObsiGate, qui
|
||||
sert la page) fournit les URL aux assets partagés — panneau Spark,
|
||||
barre d'apps, liens inter-apps lisent ``window.DRACO_URLS`` et ne
|
||||
portent que les défauts officiels en repli. Les valeurs viennent
|
||||
de :mod:`backend.draco_config` (environnement ; défauts officiels,
|
||||
noms en ``-dev`` déclarés par le seul ``.env`` du poste). Le script
|
||||
injecté passe par le même mécanisme de nonce CSP que les autres
|
||||
scripts inline de la page.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from backend.csp import inject_csp_nonce
|
||||
from backend.draco_config import urls_publiques
|
||||
|
||||
html = (FRONTEND_DIR / "index.html").read_text(encoding="utf-8")
|
||||
script = (
|
||||
"<script>window.DRACO_URLS = "
|
||||
+ _json.dumps(urls_publiques()).replace("<", "\\u003c")
|
||||
+ ";</script>"
|
||||
)
|
||||
if "</head>" in html:
|
||||
html = html.replace("</head>", script + "</head>", 1)
|
||||
else: # repli défensif : tout en tête de document
|
||||
html = script + html
|
||||
return inject_csp_nonce(html, request.state.csp_nonce)
|
||||
|
||||
if FRONTEND_DIR.exists():
|
||||
# ``Cache-Control`` for /static is set by SecurityHeadersMiddleware (no-cache).
|
||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||
@@ -907,5 +943,5 @@ if FRONTEND_DIR.exists():
|
||||
"""Serve the SPA index.html for all non-API routes."""
|
||||
index_file = FRONTEND_DIR / "index.html"
|
||||
if index_file.exists():
|
||||
return HTMLResponse(content=_html_with_nonce(request, "index.html"), headers={"Cache-Control": "no-cache"})
|
||||
return HTMLResponse(content=_index_html(request), headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(status_code=404, detail="Frontend not found")
|
||||
|
||||
Reference in New Issue
Block a user