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:
+4
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
|||||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||||
> [Unreleased](#unreleased). La dernière version livrée est **2.85.1**.
|
> [Unreleased](#unreleased). La dernière version livrée est **2.85.2**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,9 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.85.2] — 2026-11-06
|
||||||
|
|
||||||
|
- **HUB-21 — Consommation des réglages généraux du Hub par ObsiGate (propagation du pilote TikTak 0.61.22, liste fermée D6(a), #229)** : nouveau client `backend/hub_reglages.py` — consultation côté serveur de `GET {hub}/api/reglages/effectifs` (endpoint public, aucun cookie, `Accept: application/json`, délai 2 s), forme validée strictement, cache mémoire 60 s sur le patron du pilote (horodatage posé avant l'appel : un échec conserve l'ancien cache, pas de rafale) ; URL du Hub résolue comme la barre commune (`draco_config.app_url("hub")` — jamais d'URL dev figée). Toute erreur (réseau, statut, JSON, forme) = **repli intégral** sur les défauts actuels. **Bannière** : fragment injecté côté serveur sous la barre commune (page principale `_index_html` et page `/recherche`), texte échappé (jamais de HTML injecté), bouton fermer avec mémorisation du texte fermé en `sessionStorage` (clé `obsigate_banniere_masquee`) ; bannière vide = aucun fragment, zéro changement visuel ; styles `.banniere-hub*` dans `frontend/style.css` (variables CSS du thème). **Drapeau `recherche_partout`** : `serve_recherche_page` remplace le jeton `{{RECHERCHE_PARTOUT}}` rendu en `data-recherche-partout` du `<main id="recherche">` de `frontend/recherche.html` ; si faux, le JS vendangé `frontend/js/recherche.js` (même modification dans les 4 hôtes applicatifs) n'interroge plus les sœurs ni la console — la recherche locale ObsiGate continue ; vrai ou Hub muet = comportement actuel. **`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 par le client, sans effet local. Le libellé « Fermer l'annonce » est un attribut d'accessibilité du fragment injecté côté serveur (patron des autres hôtes), pas un texte d'interface du SPA : pas de clé i18n ajoutée. Fiche : `docs/features/hub-reglages-obsigate-229.md`. Tests : `tests/test_hub_reglages.py` (client : contrat exact, forme inattendue, erreurs, cache 60 s, ancien cache sans rafale ; intégration : bannière présente et échappée sur `/` et `/recherche`, absente si vide ou Hub muet, attribut du drapeau selon la valeur) ; `tests/frontend/recherche.test.mjs` étendu (`federationAutorisee`).
|
||||||
## [2.85.1] — 2026-11-06
|
## [2.85.1] — 2026-11-06
|
||||||
|
|
||||||
- **HUB-19 — Propagation des rôles du Hub à ObsiGate (suite du pilote ShaarIt, fusion Hub v2 §2.2, décision D3(a), #228)** : ObsiGate, à la fois émettrice et consommatrice du SSO, consomme à son tour le registre des rôles du Hub. Nouveau client `backend/hub_roles.py` : à l'ouverture de session draco (validation locale `draco_session.user_for_draco`), consultation de `GET {hub}/api/roles/obsigate` (en-tête Cookie complet relayé, `Accept: application/json`, délai 2 s), cache mémoire 60 s par `sub` sur le patron du pilote (horodatage posé avant l'appel : un échec conserve l'ancien cache, pas de rafale) ; URL du Hub résolue comme la barre commune (`draco_config.app_url("hub")` : `DRACO_HUB_URL` explicite, dérivation par l'hôte sœur, défaut officiel — jamais d'URL dev figée). **Effets, sessions établies par draco seulement** : niveau connu `aucun` → session draco refusée, `require_auth` répond 403 avec un message explicite en français (l'authentification native d'ObsiGate — l'émettrice, son accès propre est le secours — n'est jamais touchée) ; routes d'administration `/api/auth/admin/*` : nouvelle garde `require_admin_hub` — canal draco + rôle connu ⇒ niveau `administrateur` du registre exigé, sessions natives et rôle inconnu (Hub injoignable ou en erreur) ⇒ règles actuelles inchangées, aucun verrouillage nouveau, et le registre n'élève aucun privilège local. Validation locale du jeton, émission, révocations et renouvellement inchangés ; aucun rôle dans le jeton. Aucun impact frontend (pas d'i18n). Fiche : `docs/features/hub-roles-obsigate-228.md`. Tests : `tests/test_hub_roles.py` (client : parsing/cache/repli/résolution d'URL ; intégration : refus draco, garde admin). **Hypothèse de version** : branche depuis le main 2.84.1, cible 2.85.1 — rebasée sur la 2.85.0 (PR « API sessions HUB-22 » fusionnée d'abord) avant fusion.
|
- **HUB-19 — Propagation des rôles du Hub à ObsiGate (suite du pilote ShaarIt, fusion Hub v2 §2.2, décision D3(a), #228)** : ObsiGate, à la fois émettrice et consommatrice du SSO, consomme à son tour le registre des rôles du Hub. Nouveau client `backend/hub_roles.py` : à l'ouverture de session draco (validation locale `draco_session.user_for_draco`), consultation de `GET {hub}/api/roles/obsigate` (en-tête Cookie complet relayé, `Accept: application/json`, délai 2 s), cache mémoire 60 s par `sub` sur le patron du pilote (horodatage posé avant l'appel : un échec conserve l'ancien cache, pas de rafale) ; URL du Hub résolue comme la barre commune (`draco_config.app_url("hub")` : `DRACO_HUB_URL` explicite, dérivation par l'hôte sœur, défaut officiel — jamais d'URL dev figée). **Effets, sessions établies par draco seulement** : niveau connu `aucun` → session draco refusée, `require_auth` répond 403 avec un message explicite en français (l'authentification native d'ObsiGate — l'émettrice, son accès propre est le secours — n'est jamais touchée) ; routes d'administration `/api/auth/admin/*` : nouvelle garde `require_admin_hub` — canal draco + rôle connu ⇒ niveau `administrateur` du registre exigé, sessions natives et rôle inconnu (Hub injoignable ou en erreur) ⇒ règles actuelles inchangées, aucun verrouillage nouveau, et le registre n'élève aucun privilège local. Validation locale du jeton, émission, révocations et renouvellement inchangés ; aucun rôle dans le jeton. Aucun impact frontend (pas d'i18n). Fiche : `docs/features/hub-roles-obsigate-228.md`. Tests : `tests/test_hub_roles.py` (client : parsing/cache/repli/résolution d'URL ; intégration : refus draco, garde admin). **Hypothèse de version** : branche depuis le main 2.84.1, cible 2.85.1 — rebasée sur la 2.85.0 (PR « API sessions HUB-22 » fusionnée d'abord) avant fusion.
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.85.1).
|
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.85.2).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Projet : ObsiGate | Version : 2.85.1 | Dernière mise à jour : Septembre 2026*
|
*Projet : ObsiGate | Version : 2.85.2 | Dernière mise à jour : Septembre 2026*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.85.1).
|
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.85.2).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.85.1 | Last updated: September 2026*
|
*Project: ObsiGate | Version: 2.85.2 | Last updated: September 2026*
|
||||||
|
|||||||
@@ -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:
|
def _index_html(request: Request) -> str:
|
||||||
"""``index.html`` + injection des URL publiques de l'écosystème (#223).
|
"""``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)
|
html = html.replace("</head>", script + "</head>", 1)
|
||||||
else: # repli défensif : tout en tête de document
|
else: # repli défensif : tout en tête de document
|
||||||
html = script + html
|
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)
|
return inject_csp_nonce(html, request.state.csp_nonce)
|
||||||
|
|
||||||
if FRONTEND_DIR.exists():
|
if FRONTEND_DIR.exists():
|
||||||
@@ -989,6 +1041,15 @@ if FRONTEND_DIR.exists():
|
|||||||
if not recherche_file.exists():
|
if not recherche_file.exists():
|
||||||
raise HTTPException(status_code=404, detail="Recherche page not found")
|
raise HTTPException(status_code=404, detail="Recherche page not found")
|
||||||
html = recherche_file.read_text(encoding="utf-8")
|
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()
|
urls = urls_publiques()
|
||||||
for jeton, cle in (
|
for jeton, cle in (
|
||||||
("{{URL_OBSIGATE}}", "obsigate"),
|
("{{URL_OBSIGATE}}", "obsigate"),
|
||||||
@@ -1008,6 +1069,11 @@ if FRONTEND_DIR.exists():
|
|||||||
html = html.replace("</head>", script + "</head>", 1)
|
html = html.replace("</head>", script + "</head>", 1)
|
||||||
else:
|
else:
|
||||||
html = script + html
|
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)
|
html = inject_csp_nonce(html, request.state.csp_nonce)
|
||||||
soeurs = " ".join(sorted({
|
soeurs = " ".join(sorted({
|
||||||
origin_of(urls.get(cle, "")) or ""
|
origin_of(urls.get(cle, "")) or ""
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.85.1"
|
version = "2.85.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.85.1"
|
version = "2.85.2"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.85.1",
|
"version": "2.85.2",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
+23
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.85.1 | **Dernière mise à jour :** 2026-11-06
|
> **Version :** 2.85.2 | **Dernière mise à jour :** 2026-11-06
|
||||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
@@ -70,6 +70,28 @@
|
|||||||
- [x] Garde `require_admin_hub` sur les routes `/api/auth/admin/*`
|
- [x] Garde `require_admin_hub` sur les routes `/api/auth/admin/*`
|
||||||
- [x] Tests `tests/test_hub_roles.py` (client + intégration du chemin draco)
|
- [x] Tests `tests/test_hub_roles.py` (client + intégration du chemin draco)
|
||||||
|
|
||||||
|
### 229. Hub HUB-21 — consommation des réglages généraux du Hub par ObsiGate
|
||||||
|
|
||||||
|
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-06 | **Statut :** ✅ livré en 2.85.2 (voir CHANGELOG ; PR en attente de fusion ; redéploiement Docker requis)
|
||||||
|
- **Fiche :** [features/hub-reglages-obsigate-229.md](./features/hub-reglages-obsigate-229.md)
|
||||||
|
- **Description :** propagation de HUB-21 (pilote TikTak 0.61.22, liste
|
||||||
|
fermée D6(a)) : consultation côté serveur de
|
||||||
|
`GET {hub}/api/reglages/effectifs` (endpoint public, délai 2 s),
|
||||||
|
cache mémoire 60 s (horodatage posé avant l'appel), repli intégral
|
||||||
|
sur les défauts actuels si le Hub est muet, en erreur ou de forme
|
||||||
|
inattendue. Bannière d'annonce injectée sous la barre commune (page
|
||||||
|
principale et /recherche, texte échappé, fermable, masquage
|
||||||
|
mémorisé en `sessionStorage`) ; drapeau `recherche_partout` rendu
|
||||||
|
en `data-recherche-partout` de la page /recherche — faux = partie
|
||||||
|
fédérée désactivée, recherche locale intacte. `domaine_defaut`
|
||||||
|
**non consommé** : pas de notion de domaine propre à ObsiGate (le
|
||||||
|
sélecteur de la barre pilote le panneau Spark).
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Client `backend/hub_reglages.py` (forme D6(a), cache/repli, URL du Hub résolue comme la barre — `draco_config.app_url("hub")`)
|
||||||
|
- [x] Bannière injectée (`backend/main.py::_fragment_banniere_hub`, `_index_html`, `serve_recherche_page`) + styles `frontend/style.css`
|
||||||
|
- [x] Jeton `{{RECHERCHE_PARTOUT}}` dans `frontend/recherche.html` + branchement du JS vendangé `frontend/js/recherche.js`
|
||||||
|
- [x] Tests `tests/test_hub_reglages.py` (client + intégration) et `tests/frontend/recherche.test.mjs` étendu
|
||||||
|
|
||||||
### 226. Hub HUB-13 — API d'administration des comptes (machine-à-machine)
|
### 226. Hub HUB-13 — API d'administration des comptes (machine-à-machine)
|
||||||
|
|
||||||
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-05 | **Statut :** ✅ livré en 2.84.0 (PR en attente de fusion — voir CHANGELOG ; redéploiement Docker requis)
|
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-05 | **Statut :** ✅ livré en 2.84.0 (PR en attente de fusion — voir CHANGELOG ; redéploiement Docker requis)
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# #229 — HUB-21 : consommation des réglages généraux du Hub par ObsiGate
|
||||||
|
|
||||||
|
**Carte :** propagation de HUB-21 (fusion #135) — le pilote TikTak
|
||||||
|
(0.61.22, en production) a posé le patron de client ; ObsiGate le
|
||||||
|
consomme à son tour, comme les autres hôtes de la suite.
|
||||||
|
**Version cible :** 2.85.2 (branche depuis le main 2.85.1).
|
||||||
|
|
||||||
|
## Contrat consommé (liste fermée D6(a))
|
||||||
|
|
||||||
|
- `GET {hub}/api/reglages/effectifs` — endpoint **public** (aucun
|
||||||
|
cookie relayé), `Accept: application/json`, délai **2 s** ;
|
||||||
|
- réponse 200, forme figée : `{"recherche_partout": bool,
|
||||||
|
"banniere": str, "domaine_defaut": "pro"|"famille"|"perso"|null,
|
||||||
|
"ttl_secondes": 60}`, `Cache-Control: public, max-age=60` ;
|
||||||
|
- **URL du Hub** : `draco_config.app_url("hub")`, la résolution de 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.
|
||||||
|
|
||||||
|
## Client — `backend/hub_reglages.py`
|
||||||
|
|
||||||
|
Sur le patron du pilote TikTak (`app/hub_reglages.py`, httpx → urllib
|
||||||
|
comme `backend/hub_roles.py`) :
|
||||||
|
|
||||||
|
- cache mémoire **60 s** ; horodatage posé **avant** l'appel : un
|
||||||
|
échec conserve l'ancien cache et ne déclenche pas de rafale vers un
|
||||||
|
Hub en panne ;
|
||||||
|
- forme validée strictement ; toute erreur (réseau, délai, statut
|
||||||
|
≠ 200, JSON illisible, forme hors contrat) → **repli intégral** sur
|
||||||
|
les défauts actuels (`recherche_partout: true`, `banniere: ""`,
|
||||||
|
`domaine_defaut: null`) — le comportement d'ObsiGate avant HUB-21.
|
||||||
|
Le module ne lève jamais ; aucun appel au démarrage.
|
||||||
|
|
||||||
|
## Consommation
|
||||||
|
|
||||||
|
1. **`banniere`** — `backend/main.py::_fragment_banniere_hub()`
|
||||||
|
construit le fragment (texte **échappé** via `html.escape`, jamais
|
||||||
|
de HTML injecté), injecté sous la barre commune par `_index_html`
|
||||||
|
(page principale, juste avant le rappel de la barre) et par
|
||||||
|
`serve_recherche_page` (page /recherche, après l'en-tête de la
|
||||||
|
barre). Le script inline (nonce CSP ajouté par `inject_csp_nonce`)
|
||||||
|
révèle le bandeau sauf si le même texte a été fermé dans la
|
||||||
|
session du navigateur (`sessionStorage`, clé
|
||||||
|
`obsigate_banniere_masquee`) : une nouvelle annonce réapparaît.
|
||||||
|
Bannière vide = aucun fragment rendu, zéro changement visuel.
|
||||||
|
Styles `.banniere-hub*` dans `frontend/style.css`, sur les
|
||||||
|
variables du thème (`var(--accent)`).
|
||||||
|
2. **`recherche_partout`** — `serve_recherche_page` remplace le jeton
|
||||||
|
`{{RECHERCHE_PARTOUT}}` de `frontend/recherche.html`, rendu en
|
||||||
|
attribut `data-recherche-partout` du `<main id="recherche">`. Le
|
||||||
|
JS vendangé `frontend/js/recherche.js` (modification identique
|
||||||
|
dans les 4 hôtes applicatifs : fonction pure
|
||||||
|
`federationAutorisee`) n'interroge plus les applications sœurs ni
|
||||||
|
la console quand la valeur est `false` — leurs sections affichent
|
||||||
|
« Recherche fédérée désactivée… » — tandis que la recherche locale
|
||||||
|
ObsiGate (même origine) continue. Attribut absent ou illisible =
|
||||||
|
fédération active (repli).
|
||||||
|
3. **`domaine_defaut` — non consommé.** ObsiGate n'a pas de notion
|
||||||
|
de domaine (Pro/Famille/Perso) propre à ses vues : le sélecteur de
|
||||||
|
domaine de la barre commune pilote le panneau Spark, pas
|
||||||
|
ObsiGate. La valeur est lue et validée par le client, sans effet
|
||||||
|
local. Seul l'hôte console (agent) applique ce réglage.
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
|
||||||
|
Aucun texte d'interface du SPA n'est ajouté : la bannière est un
|
||||||
|
contenu d'annonce servi par le Hub (texte libre de l'administrateur),
|
||||||
|
et le libellé « Fermer l'annonce » est l'attribut d'accessibilité du
|
||||||
|
fragment injecté côté serveur, sur le même patron que les autres
|
||||||
|
hôtes. Pas de clé `frontend/locales/*` ajoutée.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `tests/test_hub_reglages.py` (nouveau) : contrat exact de l'appel
|
||||||
|
(URL, aucun cookie, délai 2 s), formes inattendues → repli, erreur
|
||||||
|
HTTP / Hub injoignable / JSON illisible → repli, cache 60 s,
|
||||||
|
ancien cache conservé sans rafale ; intégration TestClient :
|
||||||
|
bannière présente et échappée sur `/` et `/recherche`, absente si
|
||||||
|
vide ou Hub muet, `data-recherche-partout` selon la valeur du
|
||||||
|
drapeau (faux / vrai / Hub muet).
|
||||||
|
- `tests/frontend/recherche.test.mjs` : assertions ajoutées pour
|
||||||
|
`federationAutorisee` (seules les valeurs explicitement négatives
|
||||||
|
désactivent ; absent/illisible = actif).
|
||||||
@@ -246,6 +246,21 @@
|
|||||||
return [tab];
|
return [tab];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* HUB-21 (propagation des réglages généraux du Hub) : le drapeau
|
||||||
|
* « Rechercher partout », rendu par l'hôte dans l'attribut
|
||||||
|
* data-recherche-partout de #recherche (« true »/« false », lu
|
||||||
|
* côté serveur depuis GET {hub}/api/reglages/effectifs, cache
|
||||||
|
* 60 s, patron du pilote TikTak 0.61.22). Seules les valeurs
|
||||||
|
* explicitement négatives désactivent la fédération ; attribut
|
||||||
|
* absent ou illisible = fédération active — le comportement
|
||||||
|
* actuel, qui est aussi le repli quand le Hub est muet.
|
||||||
|
* Fonction pure, exposée aux tests. */
|
||||||
|
function federationAutorisee(valeur) {
|
||||||
|
if (valeur == null) return true;
|
||||||
|
var v = String(valeur).trim().toLowerCase();
|
||||||
|
return v !== "false" && v !== "0" && v !== "non";
|
||||||
|
}
|
||||||
|
|
||||||
/* Troncature d'affichage : liste complète dans l'onglet propre à
|
/* Troncature d'affichage : liste complète dans l'onglet propre à
|
||||||
* l'application, PER_APP_LIMIT partout ailleurs (vue « Tout »). */
|
* l'application, PER_APP_LIMIT partout ailleurs (vue « Tout »). */
|
||||||
function pageItems(items, full) {
|
function pageItems(items, full) {
|
||||||
@@ -514,6 +529,7 @@
|
|||||||
visibleKeys: visibleKeys,
|
visibleKeys: visibleKeys,
|
||||||
pageItems: pageItems,
|
pageItems: pageItems,
|
||||||
buildSearchUrl: buildSearchUrl,
|
buildSearchUrl: buildSearchUrl,
|
||||||
|
federationAutorisee: federationAutorisee,
|
||||||
tabCounts: tabCounts,
|
tabCounts: tabCounts,
|
||||||
searchPath: searchPath,
|
searchPath: searchPath,
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
@@ -609,6 +625,14 @@
|
|||||||
".</p>";
|
".</p>";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (state.status === "desactivee") {
|
||||||
|
count.textContent = "";
|
||||||
|
body.innerHTML =
|
||||||
|
'<p class="rp-state">Recherche fédérée désactivée dans les ' +
|
||||||
|
"réglages généraux de la suite (Hub) : cette application " +
|
||||||
|
"n'est pas interrogée.</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (state.status === "idle") {
|
if (state.status === "idle") {
|
||||||
count.textContent = "";
|
count.textContent = "";
|
||||||
body.innerHTML =
|
body.innerHTML =
|
||||||
@@ -651,6 +675,8 @@
|
|||||||
var input = document.getElementById("rp-q");
|
var input = document.getElementById("rp-q");
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
var hostApp = root.getAttribute("data-host") || "";
|
var hostApp = root.getAttribute("data-host") || "";
|
||||||
|
var federation = federationAutorisee(
|
||||||
|
root.getAttribute("data-recherche-partout"));
|
||||||
var explicit = {};
|
var explicit = {};
|
||||||
APPS.forEach(function (app) {
|
APPS.forEach(function (app) {
|
||||||
var v = root.getAttribute("data-base-" + app.key);
|
var v = root.getAttribute("data-base-" + app.key);
|
||||||
@@ -772,9 +798,25 @@
|
|||||||
applyTab(tab, opts);
|
applyTab(tab, opts);
|
||||||
/* La console n'est interrogée qu'une fois affichée. */
|
/* La console n'est interrogée qu'une fois affichée. */
|
||||||
if (parseTab(tab) === "console" && currentQ && consoleFetchedQ !== currentQ) {
|
if (parseTab(tab) === "console" && currentQ && consoleFetchedQ !== currentQ) {
|
||||||
|
if (!federation) {
|
||||||
|
var secConsOnglet = sections.console;
|
||||||
|
if (secConsOnglet) {
|
||||||
|
states.console = {
|
||||||
|
status: "desactivee",
|
||||||
|
items: [],
|
||||||
|
message: "",
|
||||||
|
q: currentQ,
|
||||||
|
base: consoleBase,
|
||||||
|
app: "console",
|
||||||
|
};
|
||||||
|
renderSection(secConsOnglet, states.console, true);
|
||||||
|
renderTabs();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
fetchConsole(currentQ, runId);
|
fetchConsole(currentQ, runId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fetchConsole(q, myRun) {
|
function fetchConsole(q, myRun) {
|
||||||
var sec = sections.console;
|
var sec = sections.console;
|
||||||
@@ -865,6 +907,22 @@
|
|||||||
APPS.forEach(function (app) {
|
APPS.forEach(function (app) {
|
||||||
var sec = sections[app.key];
|
var sec = sections[app.key];
|
||||||
if (!sec) return;
|
if (!sec) return;
|
||||||
|
if (!federation && app.key !== hostApp) {
|
||||||
|
/* HUB-21 : fédération désactivée par le Hub — la section
|
||||||
|
* sœur n'est pas interrogée, la recherche locale
|
||||||
|
* (app.key === hostApp, même origine) continue. */
|
||||||
|
states[app.key] = {
|
||||||
|
status: "desactivee",
|
||||||
|
items: [],
|
||||||
|
message: "",
|
||||||
|
q: q,
|
||||||
|
base: bases[app.key],
|
||||||
|
app: app.key,
|
||||||
|
};
|
||||||
|
renderSection(sec, states[app.key], activeTab === app.key);
|
||||||
|
renderTabs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
states[app.key] = {
|
states[app.key] = {
|
||||||
status: "loading",
|
status: "loading",
|
||||||
items: [],
|
items: [],
|
||||||
@@ -915,7 +973,21 @@
|
|||||||
});
|
});
|
||||||
/* Console : seulement si son onglet est affiché ; sinon son
|
/* Console : seulement si son onglet est affiché ; sinon son
|
||||||
* compteur reste vide (pas de faux 0) jusqu'à son activation. */
|
* compteur reste vide (pas de faux 0) jusqu'à son activation. */
|
||||||
if (activeTab === "console") {
|
if (activeTab === "console" && !federation) {
|
||||||
|
var secConsRecherche = sections.console;
|
||||||
|
if (secConsRecherche) {
|
||||||
|
states.console = {
|
||||||
|
status: "desactivee",
|
||||||
|
items: [],
|
||||||
|
message: "",
|
||||||
|
q: q,
|
||||||
|
base: consoleBase,
|
||||||
|
app: "console",
|
||||||
|
};
|
||||||
|
renderSection(secConsRecherche, states.console, true);
|
||||||
|
renderTabs();
|
||||||
|
}
|
||||||
|
} else if (activeTab === "console") {
|
||||||
fetchConsole(q, myRun);
|
fetchConsole(q, myRun);
|
||||||
} else {
|
} else {
|
||||||
states.console = {
|
states.console = {
|
||||||
|
|||||||
@@ -129,6 +129,7 @@
|
|||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<div class="content-area">
|
<div class="content-area">
|
||||||
<main class="rp-page" id="recherche" data-host="obsigate"
|
<main class="rp-page" id="recherche" data-host="obsigate"
|
||||||
|
data-recherche-partout="{{RECHERCHE_PARTOUT}}"
|
||||||
data-base-obsigate="{{URL_OBSIGATE}}"
|
data-base-obsigate="{{URL_OBSIGATE}}"
|
||||||
data-base-flowdeck="{{URL_FLOWDECK}}"
|
data-base-flowdeck="{{URL_FLOWDECK}}"
|
||||||
data-base-tiktak="{{URL_TIKTAK}}"
|
data-base-tiktak="{{URL_TIKTAK}}"
|
||||||
|
|||||||
@@ -14378,3 +14378,12 @@ body.has-draco-bar .app-container { height: calc(100vh - 60px); }
|
|||||||
.coffres-context-menu { position: fixed; z-index: 1000; min-width: 220px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
|
.coffres-context-menu { position: fixed; z-index: 1000; min-width: 220px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
|
||||||
.coffres-context-item { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; }
|
.coffres-context-item { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; }
|
||||||
.coffres-context-item:hover { background: var(--bg-hover, #1f2430); }
|
.coffres-context-item:hover { background: var(--bg-hover, #1f2430); }
|
||||||
|
|
||||||
|
/* Bannière d'annonce des réglages généraux du Hub (HUB-21) :
|
||||||
|
bandeau injecté côté serveur sous la barre commune, fermable
|
||||||
|
(masquage mémorisé pour la session du navigateur). */
|
||||||
|
.banniere-hub { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: var(--accent, #7c5cd6); color: #fff; font-size: 14px; }
|
||||||
|
.banniere-hub[hidden] { display: none; }
|
||||||
|
.banniere-hub-texte { flex: 1; overflow-wrap: anywhere; }
|
||||||
|
.banniere-hub-fermer { flex: none; background: transparent; border: none; color: inherit; font-size: 15px; line-height: 1; padding: 4px 6px; cursor: pointer; border-radius: 6px; }
|
||||||
|
.banniere-hub-fermer:hover { background: rgba(255, 255, 255, 0.18); }
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.85.1",
|
"version": "2.85.2",
|
||||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||||
"main": "patch.js",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -207,4 +207,14 @@ ok(rc.recognized === true && rc.items.length === 0, "console : resultats vide re
|
|||||||
ok(rp.consoleLinkFor({ type: "discussion", id: "c1" }) === "/?section=conversation&disc=c1", "consoleLinkFor discussion");
|
ok(rp.consoleLinkFor({ type: "discussion", id: "c1" }) === "/?section=conversation&disc=c1", "consoleLinkFor discussion");
|
||||||
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { agent: "https://agent-perso.example/" } }) === "https://agent-perso.example/", "console : href onglet barre agent accepte");
|
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { agent: "https://agent-perso.example/" } }) === "https://agent-perso.example/", "console : href onglet barre agent accepte");
|
||||||
|
|
||||||
|
/* federationAutorisee (HUB-21) : seules les valeurs explicitement
|
||||||
|
négatives désactivent la fédération ; absent/illisible = actif
|
||||||
|
(repli = comportement actuel, Hub muet compris). */
|
||||||
|
ok(rp.federationAutorisee("false") === false, "fédération : false désactive");
|
||||||
|
ok(rp.federationAutorisee("0") === false, "fédération : 0 désactive");
|
||||||
|
ok(rp.federationAutorisee("true") === true, "fédération : true active");
|
||||||
|
ok(rp.federationAutorisee(null) === true, "fédération : attribut absent = actif");
|
||||||
|
ok(rp.federationAutorisee("") === true, "fédération : vide = actif");
|
||||||
|
ok(rp.federationAutorisee("nimporte") === true, "fédération : illisible = actif");
|
||||||
|
|
||||||
console.log(`recherche.test.mjs : ${n} assertions OK`);
|
console.log(`recherche.test.mjs : ${n} assertions OK`);
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# tests/test_hub_reglages.py
|
||||||
|
# Hub HUB-21 (propagation) — Réglages généraux du Hub consommés par
|
||||||
|
# ObsiGate : client (parsing de la forme figée, cache 60 s, repli
|
||||||
|
# intégral), bannière injectée côté serveur (page principale et page
|
||||||
|
# /recherche, texte échappé) et drapeau ``recherche_partout`` rendu
|
||||||
|
# en attribut de la page /recherche. Patron posé par l'hôte pilote
|
||||||
|
# TikTak 0.61.22. ``domaine_defaut`` n'est pas appliqué : ObsiGate
|
||||||
|
# n'a pas de notion de domaine propre (voir backend/hub_reglages.py).
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend import hub_reglages
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _hub_env(monkeypatch):
|
||||||
|
"""Cache vierge + Hub de test explicite pour chaque test."""
|
||||||
|
hub_reglages.reinitialiser_cache()
|
||||||
|
monkeypatch.setenv("DRACO_HUB_URL", "https://hub-test.dracodev.net")
|
||||||
|
yield
|
||||||
|
hub_reglages.reinitialiser_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def _reponse(corps=None, statut=200):
|
||||||
|
"""Fausse réponse urlopen portant un payload du contrat D6(a)."""
|
||||||
|
if corps is None:
|
||||||
|
corps = {"recherche_partout": True, "banniere": "",
|
||||||
|
"domaine_defaut": None, "ttl_secondes": 60}
|
||||||
|
charge = json.dumps(corps).encode("utf-8")
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
status = statut
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return charge
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return _Resp()
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_consultation(monkeypatch, reglages, appels=None):
|
||||||
|
"""Remplace la consultation HTTP par des réglages fixes."""
|
||||||
|
def _faux(url):
|
||||||
|
if appels is not None:
|
||||||
|
appels.append(url)
|
||||||
|
return reglages
|
||||||
|
monkeypatch.setattr(hub_reglages, "_consulter_hub", _faux)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Client : parsing du contrat ─────────────────────────────────────
|
||||||
|
|
||||||
|
def test_consultation_parse_le_contrat(monkeypatch):
|
||||||
|
vu = {}
|
||||||
|
|
||||||
|
def _faux_urlopen(requete, timeout=None):
|
||||||
|
vu["url"] = requete.full_url
|
||||||
|
vu["cookie"] = requete.get_header("Cookie")
|
||||||
|
vu["accept"] = requete.get_header("Accept")
|
||||||
|
vu["timeout"] = timeout
|
||||||
|
return _reponse({"recherche_partout": False,
|
||||||
|
"banniere": "Maintenance dimanche",
|
||||||
|
"domaine_defaut": "famille", "ttl_secondes": 60})
|
||||||
|
|
||||||
|
monkeypatch.setattr(hub_reglages.urllib.request, "urlopen", _faux_urlopen)
|
||||||
|
reglages = hub_reglages.reglages_effectifs()
|
||||||
|
assert reglages == {"recherche_partout": False,
|
||||||
|
"banniere": "Maintenance dimanche",
|
||||||
|
"domaine_defaut": "famille"}
|
||||||
|
assert vu["url"] == "https://hub-test.dracodev.net/api/reglages/effectifs"
|
||||||
|
assert vu["cookie"] is None # endpoint public : aucun cookie
|
||||||
|
assert vu["accept"] == "application/json"
|
||||||
|
assert vu["timeout"] == 2 # délai du contrat
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("corps", [
|
||||||
|
{"recherche_partout": "oui", "banniere": "", "domaine_defaut": None},
|
||||||
|
{"recherche_partout": True, "banniere": 42, "domaine_defaut": None},
|
||||||
|
{"recherche_partout": True, "banniere": "", "domaine_defaut": "travail"},
|
||||||
|
{"banniere": "", "domaine_defaut": None},
|
||||||
|
["pas", "un", "dict"],
|
||||||
|
])
|
||||||
|
def test_consultation_forme_inattendue_repli(monkeypatch, corps):
|
||||||
|
monkeypatch.setattr(hub_reglages.urllib.request, "urlopen",
|
||||||
|
lambda requete, timeout=None: _reponse(corps))
|
||||||
|
assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS
|
||||||
|
|
||||||
|
|
||||||
|
def test_consultation_erreur_http_repli(monkeypatch):
|
||||||
|
monkeypatch.setattr(hub_reglages.urllib.request, "urlopen",
|
||||||
|
lambda requete, timeout=None: _reponse(statut=500))
|
||||||
|
assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS
|
||||||
|
assert hub_reglages.banniere() == ""
|
||||||
|
assert hub_reglages.recherche_partout() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_consultation_hub_injoignable_repli(monkeypatch):
|
||||||
|
def _leve(requete, timeout=None):
|
||||||
|
raise urllib.error.URLError("injoignable")
|
||||||
|
monkeypatch.setattr(hub_reglages.urllib.request, "urlopen", _leve)
|
||||||
|
assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS
|
||||||
|
|
||||||
|
|
||||||
|
def test_consultation_json_illisible_repli(monkeypatch):
|
||||||
|
class _Resp:
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return b"pas du json"
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(hub_reglages.urllib.request, "urlopen",
|
||||||
|
lambda requete, timeout=None: _Resp())
|
||||||
|
assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS
|
||||||
|
|
||||||
|
|
||||||
|
# ── Client : cache 60 s, ancien cache, pas de rafale ────────────────
|
||||||
|
|
||||||
|
def test_cache_60_s(monkeypatch):
|
||||||
|
appels = []
|
||||||
|
_patch_consultation(monkeypatch, {
|
||||||
|
"recherche_partout": False, "banniere": "Annonce A",
|
||||||
|
"domaine_defaut": None}, appels)
|
||||||
|
assert hub_reglages.banniere() == "Annonce A"
|
||||||
|
assert hub_reglages.recherche_partout() is False
|
||||||
|
assert len(appels) == 1 # cache 60 s
|
||||||
|
hub_reglages._cache_at = time.monotonic() - 61
|
||||||
|
assert hub_reglages.banniere() == "Annonce A"
|
||||||
|
assert len(appels) == 2 # expiration → rappel
|
||||||
|
|
||||||
|
|
||||||
|
def test_echec_ancien_cache_sans_rafale(monkeypatch):
|
||||||
|
appels = []
|
||||||
|
_patch_consultation(monkeypatch, {
|
||||||
|
"recherche_partout": True, "banniere": "Annonce A",
|
||||||
|
"domaine_defaut": None}, appels)
|
||||||
|
assert hub_reglages.banniere() == "Annonce A"
|
||||||
|
hub_reglages._cache_at = time.monotonic() - 61
|
||||||
|
_patch_consultation(monkeypatch, None, appels) # Hub tombe
|
||||||
|
assert hub_reglages.banniere() == "Annonce A" # ancien cache
|
||||||
|
assert len(appels) == 2 # pas de rafale
|
||||||
|
assert hub_reglages.banniere() == "Annonce A"
|
||||||
|
assert len(appels) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ── Intégration : bannière et drapeau dans les pages servies ────────
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def hub_banniere(monkeypatch):
|
||||||
|
etat = {"reglages": None}
|
||||||
|
monkeypatch.setattr(hub_reglages, "_consulter_hub",
|
||||||
|
lambda url: etat["reglages"])
|
||||||
|
hub_reglages.reinitialiser_cache()
|
||||||
|
return etat
|
||||||
|
|
||||||
|
|
||||||
|
def test_banniere_presente_et_echappee_index(client, hub_banniere):
|
||||||
|
hub_banniere["reglages"] = {
|
||||||
|
"recherche_partout": True,
|
||||||
|
"banniere": "Maintenance dimanche <b>gras</b> & cie",
|
||||||
|
"domaine_defaut": None}
|
||||||
|
r = client.get("/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert 'id="banniere-hub"' in r.text
|
||||||
|
assert "Maintenance dimanche <b>gras</b> & cie" in r.text
|
||||||
|
assert "obsigate_banniere_masquee" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_banniere_absente_si_vide_ou_hub_muet(client, hub_banniere):
|
||||||
|
hub_banniere["reglages"] = {
|
||||||
|
"recherche_partout": True, "banniere": "", "domaine_defaut": "pro"}
|
||||||
|
r = client.get("/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "banniere-hub" not in r.text
|
||||||
|
hub_banniere["reglages"] = None
|
||||||
|
hub_reglages.reinitialiser_cache()
|
||||||
|
r = client.get("/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "banniere-hub" not in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_recherche_banniere_et_drapeau(client, hub_banniere):
|
||||||
|
hub_banniere["reglages"] = {
|
||||||
|
"recherche_partout": False, "banniere": "Annonce recherche",
|
||||||
|
"domaine_defaut": None}
|
||||||
|
r = client.get("/recherche")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert 'data-recherche-partout="false"' in r.text
|
||||||
|
assert 'id="banniere-hub"' in r.text
|
||||||
|
assert "Annonce recherche" in r.text
|
||||||
|
hub_banniere["reglages"] = {
|
||||||
|
"recherche_partout": True, "banniere": "", "domaine_defaut": None}
|
||||||
|
hub_reglages.reinitialiser_cache()
|
||||||
|
r = client.get("/recherche")
|
||||||
|
assert 'data-recherche-partout="true"' in r.text
|
||||||
|
assert "banniere-hub" not in r.text
|
||||||
|
hub_banniere["reglages"] = None # Hub muet → défaut actif
|
||||||
|
hub_reglages.reinitialiser_cache()
|
||||||
|
r = client.get("/recherche")
|
||||||
|
assert 'data-recherche-partout="true"' in r.text
|
||||||
Reference in New Issue
Block a user