feat: HUB-21 — réglages généraux du Hub consommés par ObsiGate (bannière + drapeau recherche_partout) #229
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
# backend/hub_reglages.py
|
||||
# Hub HUB-21 (propagation) — Réglages généraux du Hub consommés par
|
||||
# ObsiGate, sur le patron posé par l'hôte pilote TikTak (0.61.22).
|
||||
#
|
||||
# Le Hub expose ``GET {hub}/api/reglages/effectifs`` — endpoint
|
||||
# **public** (aucun cookie relayé), ``Accept: application/json``,
|
||||
# délai 2 s. Réponse 200, forme figée (liste fermée D6(a)) :
|
||||
# ``{"recherche_partout": bool, "banniere": str,
|
||||
# "domaine_defaut": "pro"|"famille"|"perso"|null,
|
||||
# "ttl_secondes": 60}``.
|
||||
#
|
||||
# Cache mémoire 60 s, horodatage posé **avant** l'appel : un échec
|
||||
# conserve l'ancien cache et ne déclenche pas de rafale de requêtes
|
||||
# vers un Hub en panne.
|
||||
#
|
||||
# **Repli intégral** : Hub injoignable, en erreur ou réponse de forme
|
||||
# inattendue → les **défauts actuels** (``recherche_partout: true``,
|
||||
# ``banniere: ""``, ``domaine_defaut: null``), c'est-à-dire le
|
||||
# comportement d'ObsiGate avant HUB-21. Ce module ne lève jamais.
|
||||
#
|
||||
# Consommation chez ObsiGate :
|
||||
# - ``banniere`` : injectée côté serveur sous la barre commune
|
||||
# (page principale et page /recherche), texte échappé ;
|
||||
# - ``recherche_partout`` : posé en attribut ``data-recherche-partout``
|
||||
# de la page /recherche — si faux, la partie fédérée est désactivée
|
||||
# (la recherche locale ObsiGate continue) ;
|
||||
# - ``domaine_defaut`` : **non consommé** — ObsiGate n'a pas de
|
||||
# notion de domaine propre ; le sélecteur de domaine de la barre
|
||||
# commune pilote le panneau Spark, pas les vues d'ObsiGate. La
|
||||
# valeur est lue et validée, sans effet local.
|
||||
#
|
||||
# URL du Hub : `draco_config.app_url("hub")` — la même résolution que
|
||||
# la barre commune (#223) : variable explicite d'abord, dérivation
|
||||
# par l'hôte sœur ensuite, défaut officiel enfin. Jamais d'URL dev
|
||||
# figée.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
logger = logging.getLogger("obsigate.hub_reglages")
|
||||
|
||||
#: Réglages par défaut = comportement actuel d'ObsiGate (repli).
|
||||
DEFAUTS = {"recherche_partout": True, "banniere": "", "domaine_defaut": None}
|
||||
|
||||
#: Domaines reconnus du contrat D6(a).
|
||||
DOMAINES = ("pro", "famille", "perso")
|
||||
|
||||
#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub (60 s).
|
||||
CACHE_TTL_SECONDS = 60
|
||||
|
||||
#: Délai maximal de la consultation du Hub (contrat : 2 s).
|
||||
DELAI_SECONDS = 2
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache: dict | None = None # derniers réglages validés
|
||||
_cache_at: float | None = None # horodatage (monotone) du dernier appel
|
||||
|
||||
|
||||
def _hub_url() -> str:
|
||||
"""Base publique du Hub, résolue comme pour la barre commune."""
|
||||
from backend.draco_config import app_url
|
||||
|
||||
return app_url("hub")
|
||||
|
||||
|
||||
def _valider(donnees) -> dict | None:
|
||||
"""Valide la forme figée du contrat ; ``None`` si inattendue."""
|
||||
if not isinstance(donnees, dict):
|
||||
return None
|
||||
recherche = donnees.get("recherche_partout")
|
||||
banniere = donnees.get("banniere")
|
||||
domaine = donnees.get("domaine_defaut")
|
||||
if not isinstance(recherche, bool):
|
||||
return None
|
||||
if not isinstance(banniere, str):
|
||||
return None
|
||||
if domaine is not None and domaine not in DOMAINES:
|
||||
return None
|
||||
return {
|
||||
"recherche_partout": recherche,
|
||||
"banniere": banniere,
|
||||
"domaine_defaut": domaine,
|
||||
}
|
||||
|
||||
|
||||
def _consulter_hub(url: str) -> dict | None:
|
||||
"""Appelle le Hub et retourne les réglages validés, ou ``None``.
|
||||
|
||||
Toute erreur (réseau, délai dépassé, statut ≠ 200, JSON
|
||||
illisible, forme hors contrat) retourne ``None`` : repli.
|
||||
Endpoint public : aucun cookie n'est relayé.
|
||||
"""
|
||||
requete = urllib.request.Request(
|
||||
url, method="GET", headers={"Accept": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(requete, timeout=DELAI_SECONDS) as reponse:
|
||||
if reponse.status != 200:
|
||||
return None
|
||||
donnees = json.loads(reponse.read().decode("utf-8"))
|
||||
except Exception: # les réglages ne cassent jamais une page
|
||||
return None
|
||||
return _valider(donnees)
|
||||
|
||||
|
||||
def reglages_effectifs() -> dict:
|
||||
"""Réglages généraux effectifs pour ObsiGate.
|
||||
|
||||
Réponse du Hub validée et mise en cache 60 s ; à défaut (Hub
|
||||
muet, en erreur ou forme inattendue), l'ancien cache s'il
|
||||
existe, sinon les défauts actuels. Ne lève jamais.
|
||||
"""
|
||||
global _cache, _cache_at
|
||||
with _lock:
|
||||
if _cache is not None and _cache_at is not None \
|
||||
and time.monotonic() - _cache_at < CACHE_TTL_SECONDS:
|
||||
return dict(_cache)
|
||||
# Horodatage posé AVANT l'appel (patron du pilote) : l'ancien
|
||||
# cache reste en vigueur, un échec ne déclenche pas de rafale.
|
||||
_cache_at = time.monotonic()
|
||||
url = f"{_hub_url().rstrip('/')}/api/reglages/effectifs"
|
||||
reglages = _consulter_hub(url)
|
||||
if reglages is None:
|
||||
logger.warning(
|
||||
"Réglages généraux du Hub indisponibles (%s) — "
|
||||
"comportement actuel conservé", url)
|
||||
with _lock:
|
||||
return dict(_cache) if _cache is not None else dict(DEFAUTS)
|
||||
with _lock:
|
||||
_cache = reglages
|
||||
_cache_at = time.monotonic()
|
||||
return dict(reglages)
|
||||
|
||||
|
||||
def banniere() -> str:
|
||||
"""Texte de la bannière d'annonce (``""`` = aucune bannière)."""
|
||||
return reglages_effectifs()["banniere"]
|
||||
|
||||
|
||||
def recherche_partout() -> bool:
|
||||
"""Drapeau de la recherche fédérée (défaut actuel : actif)."""
|
||||
return bool(reglages_effectifs()["recherche_partout"])
|
||||
|
||||
|
||||
def reinitialiser_cache() -> None:
|
||||
"""Vide le cache (tests)."""
|
||||
global _cache, _cache_at
|
||||
with _lock:
|
||||
_cache = None
|
||||
_cache_at = None
|
||||
@@ -859,6 +859,49 @@ def _html_with_nonce(request: Request, name: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _fragment_banniere_hub() -> str:
|
||||
"""Bandeau d'annonce des réglages généraux du Hub (HUB-21).
|
||||
|
||||
Retourne le fragment HTML à insérer sous la barre commune, ou
|
||||
``""`` quand le Hub n'annonce rien (bannière vide ou Hub muet —
|
||||
repli intégral du client :mod:`backend.hub_reglages`). Le texte
|
||||
est **échappé** (jamais de HTML injecté). Le petit script inline
|
||||
(le nonce CSP est ajouté par l'appelant via ``inject_csp_nonce``)
|
||||
révèle le bandeau sauf si le même texte a déjà été fermé dans la
|
||||
session du navigateur (``sessionStorage``, clé
|
||||
``obsigate_banniere_masquee``) : une nouvelle annonce réapparaît.
|
||||
"""
|
||||
import html as _html
|
||||
|
||||
from backend import hub_reglages
|
||||
|
||||
texte = hub_reglages.banniere()
|
||||
if not texte:
|
||||
return ""
|
||||
return (
|
||||
'<div class="banniere-hub" id="banniere-hub" role="status" hidden>'
|
||||
f'<span class="banniere-hub-texte">{_html.escape(texte)}</span>'
|
||||
'<button type="button" class="banniere-hub-fermer"'
|
||||
' id="banniere-hub-fermer" aria-label="Fermer l\'annonce">✕</button>'
|
||||
"</div>"
|
||||
"<script>(function(){try{"
|
||||
'var b=document.getElementById("banniere-hub");if(!b)return;'
|
||||
'var t=b.querySelector(".banniere-hub-texte").textContent;'
|
||||
'if(sessionStorage.getItem("obsigate_banniere_masquee")===t)'
|
||||
"{b.remove();return;}"
|
||||
"b.hidden=false;"
|
||||
'document.getElementById("banniere-hub-fermer")'
|
||||
'.addEventListener("click",function(){'
|
||||
'try{sessionStorage.setItem("obsigate_banniere_masquee",t);}'
|
||||
"catch(e){}"
|
||||
"b.remove();});"
|
||||
"}catch(e){"
|
||||
'var b2=document.getElementById("banniere-hub");'
|
||||
"if(b2)b2.hidden=false;"
|
||||
"}})();</script>"
|
||||
)
|
||||
|
||||
|
||||
def _index_html(request: Request) -> str:
|
||||
"""``index.html`` + injection des URL publiques de l'écosystème (#223).
|
||||
|
||||
@@ -886,6 +929,15 @@ def _index_html(request: Request) -> str:
|
||||
html = html.replace("</head>", script + "</head>", 1)
|
||||
else: # repli défensif : tout en tête de document
|
||||
html = script + html
|
||||
# Bannière d'annonce du Hub (HUB-21) : sous la barre commune,
|
||||
# juste avant son rappel ; vide = aucun changement visuel.
|
||||
fragment = _fragment_banniere_hub()
|
||||
if fragment:
|
||||
marqueur = '<div class="draco-bar-rappel"'
|
||||
if marqueur in html:
|
||||
html = html.replace(marqueur, fragment + marqueur, 1)
|
||||
elif "</header>" in html:
|
||||
html = html.replace("</header>", "</header>" + fragment, 1)
|
||||
return inject_csp_nonce(html, request.state.csp_nonce)
|
||||
|
||||
if FRONTEND_DIR.exists():
|
||||
@@ -989,6 +1041,15 @@ if FRONTEND_DIR.exists():
|
||||
if not recherche_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Recherche page not found")
|
||||
html = recherche_file.read_text(encoding="utf-8")
|
||||
# Drapeau « Rechercher partout » des réglages généraux du Hub
|
||||
# (HUB-21) : faux = la partie fédérée de la page est désactivée
|
||||
# par le JS vendangé, la recherche locale continue ; Hub muet
|
||||
# = défaut actuel (actif).
|
||||
from backend import hub_reglages
|
||||
|
||||
html = html.replace(
|
||||
"{{RECHERCHE_PARTOUT}}",
|
||||
"true" if hub_reglages.recherche_partout() else "false")
|
||||
urls = urls_publiques()
|
||||
for jeton, cle in (
|
||||
("{{URL_OBSIGATE}}", "obsigate"),
|
||||
@@ -1008,6 +1069,11 @@ if FRONTEND_DIR.exists():
|
||||
html = html.replace("</head>", script + "</head>", 1)
|
||||
else:
|
||||
html = script + html
|
||||
# Bannière d'annonce du Hub (HUB-21) : sous la barre commune ;
|
||||
# vide = aucun changement visuel.
|
||||
fragment = _fragment_banniere_hub()
|
||||
if fragment and "</header>" in html:
|
||||
html = html.replace("</header>", "</header>" + fragment, 1)
|
||||
html = inject_csp_nonce(html, request.state.csp_nonce)
|
||||
soeurs = " ".join(sorted({
|
||||
origin_of(urls.get(cle, "")) or ""
|
||||
|
||||
Reference in New Issue
Block a user