Compare commits

...
6 Commits
Author SHA1 Message Date
Spark 68d3dd85a9 feat: validation locale du jeton draco_session — repli SSO dans get_current_user (#217)
CI / lint (push) Successful in 3m34s
CI / security (push) Successful in 2m17s
CI / test (push) Successful in 5m38s
CI / build (push) Successful in 2m15s
CI / e2e (push) Successful in 18m31s
2026-10-20 23:47:45 -04:00
Spark cb80b08f77 feat: émettrice SSO draco_session — cookie partagé .dracodev.net, renouvellement et révocation (#216)
CI / lint (push) Successful in 3m32s
CI / security (push) Successful in 2m18s
CI / test (push) Successful in 5m38s
CI / build (push) Successful in 3m52s
CI / e2e (push) Successful in 19m0s
2026-10-20 23:33:52 -04:00
Spark e5577b9996 fix: planchers de sécurité des dépendances — pip-audit vierge (aiohttp, mcp, pillow, certifi)
CI / lint (push) Successful in 3m40s
CI / security (push) Successful in 2m23s
CI / test (push) Successful in 5m38s
CI / build (push) Successful in 3m16s
CI / e2e (push) Successful in 21m7s
2026-10-20 23:10:34 -04:00
Spark 9bc31d069b feat: barre d'apps commune et références croisées des notes (#215)
CI / lint (push) Successful in 3m32s
CI / security (push) Failing after 2m27s
CI / test (push) Successful in 5m36s
CI / build (push) Successful in 2m12s
CI / e2e (push) Successful in 18m18s
2026-10-20 22:20:48 -04:00
bruno 411667fd88 feat: menu contextuel Déplacer/Dupliquer sidebar + réparation drag & drop interne #201 #BUG-117
CI / lint (push) Successful in 3m13s
CI / security (push) Successful in 1m59s
CI / test (push) Successful in 5m16s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 18m2s
2026-10-15 10:02:48 -04:00
bruno ed9029d760 fix: éditeur Forge — textarea qui ne grandit pas après insertion programmatique (tableau/code) #BUG-116
CI / lint (push) Successful in 3m17s
CI / security (push) Successful in 2m0s
CI / test (push) Successful in 5m14s
CI / build (push) Successful in 1m49s
CI / e2e (push) Successful in 17m57s
2026-10-14 22:09:28 -04:00
41 changed files with 2301 additions and 31 deletions
+109 -1
View File
@@ -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.68.5**. > [Unreleased](#unreleased). La dernière version livrée est **2.72.0**.
--- ---
@@ -14,6 +14,105 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
--- ---
## [2.72.0] — 2026-10-20
### Ajouté
- **Fusion ACC-3.2 (#217) — validation locale du jeton
`draco_session`** : `get_current_user` accepte le cookie partagé à
défaut de jeton natif valide ; le `sub` étant l'identifiant
ObsiGate, la correspondance vers le compte local est directe
(compte actif et garde-fou « changement de mot de passe » exigés).
Fiche : `docs/features/fusion-acc32-validation-locale-217.md`.
---
## [2.71.0] — 2026-10-20
### Ajouté
- **Fusion ACC-3.1 (#216) — émettrice SSO `draco_session`** : ObsiGate
émet le cookie de session partagé de l'écosystème dracodev (JWS
HS256, claims minimaux, 12 h, domaine `.dracodev.net`) à chaque
connexion, le renouvelle de façon glissante (middleware dédié), le
révoque à la déconnexion (liste des `jti` exposée aux autres apps
sous clé machine) et offre `GET /sso/authorize` avec `return_to`
en liste blanche anti open-redirect. Inerte tant que
`DRACO_SESSION_SECRET` n'est pas configuré. Fiche :
`docs/features/fusion-acc31-sso-draco-session-216.md`.
---
## [2.70.1] — 2026-10-20
### Corrigé
- **Sécurité des dépendances (CI `pip-audit` rouge sur 2.70.0)** —
planchers relevés dans `backend/requirements.txt`, selon le patron
déjà en place dans le fichier : `aiohttp>=3.14.4` (8 CVE corrigées en
3.14.4, dont CVE-2026-111063/111064), `mcp==1.30.0` (CVE-2026-59951),
`pillow>=13.0.0` (8 CVE corrigées en 13.0.0, dont CVE-2026-112895) et
plancher explicite `certifi>=2026.10.19` (PYSEC-2026-4320, dépendance
transitive). `pip-audit` redevient vierge ; aucune ligne de code
applicatif n'est touchée.
---
## [2.70.0] — 2026-10-20
### Ajouté
- **#215 — Fusion bloc 1 : barre d'apps commune et références croisées des
notes** (programme Fusion, issues Gitea #5/#6/#7 ; contrats
`dracodev-commons` v0.1.0) :
- **Barre d'apps commune** en tête de l'interface (snippet figé vendangé :
`frontend/draco-barre.css/.js`, markup dans `index.html`) — navigation
vers ObsiGate, Flowdeck, TikTak et Kanbante, ObsiGate marquée active,
sélecteur de domaine mémorisé ; la recherche, le rail et le menu délèguent
aux contrôles natifs d'ObsiGate.
- **Références croisées `external_ref`** : section « Liens » dans la vue
d'une note — coller une URL profonde (Flowdeck, TikTak, issue Gitea =
carte Kanbante, ObsiGate) crée la référence (libellé automatique ou
libre, pastille de couleur de l'app cible). Backend : stockage JSON par
utilisateur (`backend/external_refs.py`), endpoints `GET/POST /api/refs`
et `DELETE /api/refs/{id}`, parseur d'URL conforme au contrat figé
(422 explicite sinon), schémas et tag OpenAPI « ExternalRefs ».
- **Notes projetées TikTak reconnues** (frontmatter `tiktak_id`) : bandeau
« Tâche TikTak projetée » et bouton « Ouvrir dans TikTak » dans la même
section (module `frontend/draco-liens.js`, point d'extension
`obsigate:file-rendered` émis par `renderFile`).
- i18n FR/EN (clés `draco.links.*`) ; tests `test_external_refs.py`.
---
## [2.69.0] — 2026-10-15
### Ajouté
- **#201 — menu contextuel « Déplacer » / « Dupliquer »** (fichiers *et* dossiers) dans la
sidebar : le modal de destination réutilise `promptTargetFolder` (celui de l'import, même
thème, sélecteur de voute + arbo filtrable), généralisé par un paramètre `opts` optionnel
(titre, résumé, libellé du bouton) ; l'emplacement actuel est pré-sélectionné ; cross-vault
refusé proprement. Nouveau endpoint `POST /api/duplicate/{vault}` (`duplicate_path` : copie
`X copie.ext`, collisions `copie 2`, `copie 3`… ; répertoires récursifs ; refus de dupliquer
un dossier dans lui-même ; index incrémental, SSE + webhooks). i18n FR/EN.
### Corrigé
- **BUG-117** — drag & drop interne de la sidebar (déplacement d'un fichier/dossier vers un
autre) inopérant et silencieux depuis #89 : la détection « dépôt externe » dans `onDrop`
testait `items.length > 0`, qui comptait aussi les formats *string* (`tree:*`) posés par
`setData` au `dragstart` interne → chaque déplacement basculait dans la branche upload et
disparaissait sans appel API ni toast. Restreinte aux vrais fichiers (`kind === 'file'`,
type `Files`, `files` non vide), avec priorité au `dragState`.
---
## [2.68.6] — 2026-10-14
---
## [2.68.5] — 2026-10-14 ## [2.68.5] — 2026-10-14
--- ---
@@ -22,6 +121,15 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Corrigé ### Corrigé
- **BUG-116** — éditeur Forge : après insertion d'un tableau (ou bloc de code,
diagramme), la textarea restait d'une seule ligne — les mutations
programmatiques de `ta.value` ne déclenchent pas l'événement `input`, seul
chemin qui rappelait `autoHeight()`. `insertAtCursor` et `wrapSelection` (les
deux points de passage de toutes les insertions) appellent désormais
`autoHeight()` + `updateLineNumbers()` — le nombre de lignes du gutter suit.
Vérifié en Playwright : tableau 4 lignes sur document d'une ligne → hauteur
51 → 189 px, gutter cohérent.
- **BUG-115** — éditeur Forge (aperçu et menus) : trois défauts — - **BUG-115** — éditeur Forge (aperçu et menus) : trois défauts —
(1) collure case à cocher/texte dans l'aperçu des checklists : le HTML du (1) collure case à cocher/texte dans l'aperçu des checklists : le HTML du
serveur ne sépare pas l'`<input>` du texte et `editor-poc.html` n'avait pas serveur ne sépare pas l'`<input>` du texte et `editor-poc.html` n'avait pas
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.68.5-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.72.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](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.68.5). Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.72.0).
--- ---
*Projet : ObsiGate | Version : 2.68.5 | Dernière mise à jour : Septembre 2026* *Projet : ObsiGate | Version : 2.72.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.68.5-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.72.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](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.68.5). See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.72.0).
--- ---
*Project: ObsiGate | Version: 2.68.5 | Last updated: September 2026* *Project: ObsiGate | Version: 2.72.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.68.5 2.72.0
+20 -6
View File
@@ -9,6 +9,7 @@ import sys
from fastapi import Depends, HTTPException, Request from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend import draco_session
from backend.services.net import get_client_ip from backend.services.net import get_client_ip
from .jwt_handler import decode_token, is_token_revoked, maybe_touch_api_token from .jwt_handler import decode_token, is_token_revoked, maybe_touch_api_token
@@ -86,20 +87,22 @@ def get_current_user(
token = request.cookies["access_token"] token = request.cookies["access_token"]
if not token: if not token:
return None return _draco_fallback(request)
payload = decode_token(token) payload = decode_token(token)
if not payload or payload.get("type") != "access": if not payload or payload.get("type") != "access":
return None return _draco_fallback(request)
# BUG-027: access tokens revoked at logout must be rejected immediately. # BUG-027: access tokens revoked at logout must be rejected immediately.
# (Le repli draco reste sûr : la déconnexion globale révoque aussi
# le jti draco et efface son cookie.)
jti = payload.get("jti") jti = payload.get("jti")
if jti and is_token_revoked(jti): if jti and is_token_revoked(jti):
return None return _draco_fallback(request)
user = get_user(payload["sub"]) user = get_user(payload["sub"])
if not user or not user.get("active"): if not user or not user.get("active"):
return None return _draco_fallback(request)
# BUG-028: a password change invalidates every token issued before it. # BUG-028: a password change invalidates every token issued before it.
pca = user.get("password_changed_at") pca = user.get("password_changed_at")
@@ -107,9 +110,9 @@ def get_current_user(
if pca is not None and iat is not None: if pca is not None and iat is not None:
try: try:
if int(iat) < int(float(pca)): if int(iat) < int(float(pca)):
return None return _draco_fallback(request)
except (TypeError, ValueError): except (TypeError, ValueError):
return None return _draco_fallback(request)
# Attach vault permissions from the token (snapshot at login time) # Attach vault permissions from the token (snapshot at login time)
user["_token_vaults"] = payload.get("vaults", []) user["_token_vaults"] = payload.get("vaults", [])
@@ -127,6 +130,17 @@ def get_current_user(
return user return user
def _draco_fallback(request: Request) -> dict | None:
"""Fusion ACC-3.2 (#9) : à défaut de jeton natif valide, le cookie
partagé draco_session authentifie l'utilisateur (sub = id
ObsiGate, correspondance directe — voir draco_session.user_for_draco).
"""
user = draco_session.user_for_draco(request)
if user is not None:
user["_request_ip"] = get_client_ip(request)
return user
def require_auth(current_user=Depends(get_current_user)): def require_auth(current_user=Depends(get_current_user)):
"""Dependency: require a valid authenticated user.""" """Dependency: require a valid authenticated user."""
if not current_user: if not current_user:
+19
View File
@@ -11,6 +11,7 @@ import re
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
from pydantic import BaseModel, validator from pydantic import BaseModel, validator
from backend import draco_session
from backend.ratelimit import is_account_rate_limited, is_rate_limited from backend.ratelimit import is_account_rate_limited, is_rate_limited
from backend.ratelimit import record_account_failure as rl_record_account_failure from backend.ratelimit import record_account_failure as rl_record_account_failure
from backend.ratelimit import record_account_success as rl_record_account_success from backend.ratelimit import record_account_success as rl_record_account_success
@@ -280,6 +281,11 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
secure=secure, secure=secure,
path="/", path="/",
) )
# Fusion ACC-3.1 (#216) : ObsiGate est l'émettrice SSO — chaque
# émission de jetons natifs s'accompagne du cookie partagé
# draco_session (inerte tant que DRACO_SESSION_SECRET n'est pas
# configuré).
draco_session.set_cookie(response, user, secure)
return { return {
"access_token": access_token, "access_token": access_token,
# OAuth2 token_type, pas un mot de passe (B105) : # OAuth2 token_type, pas un mot de passe (B105) :
@@ -396,6 +402,19 @@ async def logout(
except Exception: except Exception:
pass pass
# Fusion ACC-3.1 (#216) : déconnexion globale — révoquer le jti du
# cookie partagé et effacer le cookie sur le domaine parent.
draco_tok = request.cookies.get(draco_session.COOKIE_NAME)
if draco_tok:
draco_claims = draco_session.decode_claims(draco_tok)
if draco_claims and draco_claims.get("jti"):
try:
draco_session.revoke(str(draco_claims["jti"]),
int(draco_claims.get("exp", 0)))
except Exception:
pass
draco_session.clear_cookie(response)
response.delete_cookie("refresh_token", path="/api/auth/refresh") response.delete_cookie("refresh_token", path="/api/auth/refresh")
response.delete_cookie("access_token", path="/") response.delete_cookie("access_token", path="/")
response.delete_cookie("access_token", path="/api") # just in case response.delete_cookie("access_token", path="/api") # just in case
+307
View File
@@ -0,0 +1,307 @@
# backend/draco_session.py
# Fusion ACC-3.1 (#216) — Émettrice SSO « draco_session » (option 2a, C2).
#
# ObsiGate est l'unique émettrice du cookie de session partagé
# ``draco_session`` (Domain=.dracodev.net) que les autres applications
# de l'écosystème (Flowdeck, TikTak, Kanbante) valideront localement
# (ACC-3.2, leurs propres tickets). Design : architecture Fusion §5.2 —
# JWS compact HS256, claims minimaux (sub, name, iat, exp 12 h, jti),
# aucun rôle dans le jeton, renouvellement glissant par l'émettrice,
# révocation par jti exposée aux autres apps.
#
# Configuration (environnement) :
# DRACO_SESSION_SECRET secret de signature partagé (obligatoire
# pour activer la fonction ; sans lui,
# aucun cookie n'est émis et rien ne change)
# DRACO_SESSION_SECRET_PREV ancien secret accepté en vérification
# pendant la fenêtre de rotation (24 h)
# DRACO_SESSION_COOKIE_DOMAIN domaine du cookie (défaut .dracodev.net ;
# vide = cookie lié à l'hôte, utile en dev)
# DRACO_SSO_MACHINE_KEY clé machine protégeant la lecture de la
# liste de révocation (GET /api/sso/revocations)
from __future__ import annotations
import json
import logging
import os
import threading
import time
import uuid
from pathlib import Path
from urllib.parse import urlparse
from jose import JWTError, jwt
logger = logging.getLogger("obsigate.draco_session")
COOKIE_NAME = "draco_session"
ALGORITHM = "HS256"
TOKEN_TTL_SECONDS = 12 * 3600 # durée de vie du jeton (design §5.2)
RENEWAL_WINDOW_SECONDS = 2 * 3600 # réémettre si exp - now < 2 h
REVOKED_FILE = Path("data/draco_revoked.json")
ALLOWED_RETURN_SUFFIX = "dracodev.net"
_lock = threading.Lock()
_revoked: dict[str, int] | None = None # jti -> exp (chargé paresseusement)
def _secret() -> str:
return os.environ.get("DRACO_SESSION_SECRET", "")
def _secret_prev() -> str:
return os.environ.get("DRACO_SESSION_SECRET_PREV", "")
def _cookie_domain() -> str:
return os.environ.get("DRACO_SESSION_COOKIE_DOMAIN", ".dracodev.net")
def enabled() -> bool:
"""La fonction est active seulement si le secret partagé est configuré."""
return bool(_secret())
# ── Révocation (jti) ────────────────────────────────────────────────────
def _load_revoked() -> dict[str, int]:
global _revoked
if _revoked is None:
try:
data = json.loads(REVOKED_FILE.read_text(encoding="utf-8"))
_revoked = {str(k): int(v) for k, v in data.items()} \
if isinstance(data, dict) else {}
except (OSError, ValueError):
_revoked = {}
return _revoked
def _save_revoked() -> None:
REVOKED_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = REVOKED_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(_revoked or {}), encoding="utf-8")
tmp.replace(REVOKED_FILE)
def revoke(jti: str, exp: int) -> None:
"""Révoque un jeton par son jti (déconnexion globale)."""
with _lock:
store = _load_revoked()
store[jti] = int(exp)
now = int(time.time())
for k in [k for k, v in store.items() if v <= now]:
del store[k] # purge des expirés
_save_revoked()
def is_revoked(jti: str) -> bool:
with _lock:
return jti in _load_revoked()
def revoked_list() -> list[dict]:
"""Liste des révocations actives (pour les autres apps, cache 60 s)."""
with _lock:
now = int(time.time())
return [{"jti": jti, "exp": exp}
for jti, exp in _load_revoked().items() if exp > now]
# ── Émission / vérification ─────────────────────────────────────────────
def issue_token(user: dict) -> tuple[str, str, int]:
"""Émet un JWS pour l'utilisateur. Retourne (jeton, jti, exp)."""
now = int(time.time())
exp = now + TOKEN_TTL_SECONDS
jti = uuid.uuid4().hex
claims = {
"sub": str(user.get("id") or user.get("username") or ""),
"name": str(user.get("display_name") or user.get("username") or ""),
"iat": now,
"exp": exp,
"jti": jti,
}
return jwt.encode(claims, _secret(), algorithm=ALGORITHM), jti, exp
def decode_claims(token: str) -> dict | None:
"""Vérifie signature et expiration (pas la révocation)."""
if not token or not enabled():
return None
for secret in (_secret(), _secret_prev()):
if not secret:
continue
try:
return jwt.decode(token, secret, algorithms=[ALGORITHM])
except JWTError:
continue
return None
def verify_token(token: str) -> dict | None:
"""decode_claims + contrôle de révocation du jti."""
claims = decode_claims(token)
if claims is None:
return None
if claims.get("jti") and is_revoked(str(claims["jti"])):
return None
return claims
def needs_renewal(claims: dict) -> bool:
"""Vrai si le jeton entre dans la fenêtre de renouvellement (< 2 h)."""
try:
return int(claims["exp"]) - int(time.time()) < RENEWAL_WINDOW_SECONDS
except (KeyError, TypeError, ValueError):
return True
def set_cookie(response, user: dict, secure: bool) -> None:
"""Pose le cookie draco_session sur une réponse (émission initiale)."""
if not enabled():
return
token, _jti, _exp = issue_token(user)
domain = _cookie_domain()
extra = {"domain": domain} if domain else {}
response.set_cookie(COOKIE_NAME, token, max_age=TOKEN_TTL_SECONDS,
httponly=True, samesite="lax", secure=secure,
path="/", **extra)
def clear_cookie(response) -> None:
domain = _cookie_domain()
extra = {"domain": domain} if domain else {}
response.delete_cookie(COOKIE_NAME, path="/", **extra)
# ── Validation locale (ACC-3.2, #9) ─────────────────────────────────────
def user_for_draco(request) -> dict | None:
"""Utilisateur ObsiGate prouvé par le cookie draco_session.
ObsiGate est à la fois émettrice et consommatrice : le `sub` du
jeton EST son identifiant d'utilisateur — la correspondance est
directe, par id. Le garde-fou « jeton antérieur au dernier
changement de mot de passe » (BUG-028 côté natif) est appliqué
à l'identique via le claim `iat`.
"""
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
if not claims:
return None
sub = str(claims.get("sub", ""))
if not sub:
return None
from backend.auth.user_store import get_all_users
for candidate in get_all_users():
if str(candidate.get("id")) != sub:
continue
if not candidate.get("active"):
return None
pca = candidate.get("password_changed_at")
iat = claims.get("iat")
if pca is not None and iat is not None:
try:
if int(iat) < int(float(pca)):
return None
except (TypeError, ValueError):
return None
user = dict(candidate)
user["_token_vaults"] = candidate.get("vaults", [])
user["_token_scope"] = "full"
user["_draco_sub"] = sub
return user
return None
# ── Renouvellement glissant (middleware) ────────────────────────────────
class DracoRenewalMiddleware:
"""Réémet le cookie quand l'utilisateur est actif chez l'émettrice.
Middleware ASGI pur (pas de BaseHTTPMiddleware : la pile d'ObsiGate
streame des réponses SSE/WS que BaseHTTPMiddleware mettrait en
tampon). On enveloppe l'envoi de la réponse : si la requête porte
un cookie d'accès natif valide et que le cookie draco est absent,
invalide ou dans sa fenêtre de renouvellement (< 2 h), on ajoute
un Set-Cookie frais aux en-têtes de la réponse. La révocation
ciblée d'un jti draco est ainsi « guérie » par la prochaine
activité authentifiée locale — la déconnexion globale, elle,
révoque aussi les jetons natifs, donc plus rien n'est réémis.
"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope.get("type") != "http" or not enabled():
await self.app(scope, receive, send)
return
cookie_header = ""
for name, value in scope.get("headers") or []:
if name == b"cookie":
cookie_header = value.decode("latin-1")
break
cookies = {}
for part in cookie_header.split(";"):
if "=" in part:
k, v = part.strip().split("=", 1)
cookies[k] = v
user = self._user_from_native(cookies.get("access_token", ""))
if user is None:
await self.app(scope, receive, send)
return
claims = verify_token(cookies.get(COOKIE_NAME, "")) \
if cookies.get(COOKIE_NAME) else None
if claims is not None and not needs_renewal(claims):
await self.app(scope, receive, send)
return
token, _jti, _exp = issue_token(user)
domain = _cookie_domain()
cookie = (f"{COOKIE_NAME}={token}; Max-Age={TOKEN_TTL_SECONDS}; "
f"Path=/; HttpOnly; SameSite=lax")
if domain:
cookie += f"; Domain={domain}"
if scope.get("scheme") == "https":
cookie += "; Secure"
async def send_with_cookie(message):
if message.get("type") == "http.response.start":
headers = list(message.get("headers") or [])
headers.append((b"set-cookie", cookie.encode("latin-1")))
message = {**message, "headers": headers}
await send(message)
await self.app(scope, receive, send_with_cookie)
@staticmethod
def _user_from_native(access_token: str):
"""Utilisateur local prouvé par le cookie d'accès natif, ou None."""
if not access_token:
return None
try:
from backend.auth.jwt_handler import decode_token
from backend.auth.user_store import get_user
payload = decode_token(access_token)
if not payload or payload.get("type") != "access":
return None
return get_user(str(payload.get("sub", "")))
except Exception: # le renouvellement ne casse jamais une requête
return None
# ── return_to (anti open-redirect) ──────────────────────────────────────
def validate_return_to(url: str) -> str | None:
"""Accepte uniquement https vers dracodev.net ou un sous-domaine."""
if not url:
return None
parsed = urlparse(url)
host = (parsed.hostname or "").lower()
if parsed.scheme != "https" or not host:
return None
if host == ALLOWED_RETURN_SUFFIX or host.endswith("." + ALLOWED_RETURN_SUFFIX):
return url
return None
+172
View File
@@ -0,0 +1,172 @@
"""Références croisées ``external_ref`` des notes — Fusion #215 (ACC-1.3).
Modèle figé par le programme Fusion dans ``dracodev-commons``
(``contrats/external_ref.schema.json``, v0.1.0) : la référence vit chez
le propriétaire de l'objet source — ici la note ObsiGate, identifiée
par ``(vault, path)`` — et pointe vers un objet d'une autre application
(projet ou page Flowdeck, tâche ou liste TikTak, carte Kanbante, note
ObsiGate) par son URL profonde (``contrats/liens-profonds.md``). Une
seule source de vérité par objet : on stocke des références, jamais
des copies du contenu cible.
Stockage : JSON par utilisateur dans ``data/external_refs/``, même
patron que :mod:`backend.history` (pas de base de données, cf.
``AGENTS.md``). Le répertoire est surchargeable par la variable
d'environnement ``OBSIGATE_EXTERNAL_REFS_DIR`` (tests).
"""
import json
import logging
import os
import re
import shutil
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, unquote, urlparse
logger = logging.getLogger("obsigate.external_refs")
def _refs_dir() -> Path:
override = os.environ.get("OBSIGATE_EXTERNAL_REFS_DIR")
directory = Path(override) if override else Path("data/external_refs")
directory.mkdir(parents=True, exist_ok=True)
return directory
def _refs_file(username: str) -> Path:
return _refs_dir() / f"{username}_refs.json"
def _read_refs(username: str) -> list[dict[str, Any]]:
file = _refs_file(username)
if not file.exists():
return []
try:
data = json.loads(file.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except Exception as e:
logger.error(f"Failed to read external refs from {file.name}: {e}")
return []
def _write_refs(username: str, refs: list[dict[str, Any]]) -> None:
file = _refs_file(username)
try:
tmp = file.with_suffix(".tmp")
tmp.write_text(json.dumps(refs, indent=2, ensure_ascii=False), encoding="utf-8")
shutil.move(str(tmp), str(file))
except Exception as e:
logger.error(f"Failed to write external refs to {file.name}: {e}")
# ------------------------------------------------------------ parseur d'URL
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.
"""
try:
u = urlparse((url or "").strip())
except ValueError:
return None
host = (u.hostname or "").lower()
if host == "tiktak.dracodev.net":
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":
frag = u.fragment or ""
if frag.startswith("file="):
cible = unquote(frag[5:])
if ":" in cible:
nom = cible.rsplit("/", 1)[-1]
return ("obsigate", "note", cible, f"ObsiGate · {nom}")
return None
if host == "flowdeck.dracodev.net":
m = re.fullmatch(r"/pages/(\d+)", u.path or "")
if m:
return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}")
m = re.fullmatch(r"/collections/(\d+)", u.path or "")
if m:
return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}")
return None
if host == "git.dracodev.net":
m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "")
if m:
owner, repo, num = m.groups()
return ("kanbante", "card", f"{owner}/{repo}#{num}", f"Kanbante · {repo} #{num}")
return None
return 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."""
if not username:
return []
return [
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``."""
for r in _read_refs(username):
if r.get("id") == ref_id:
return r
return None
def add_ref(
username: str,
vault: str,
path: str,
target_app: str,
target_type: str,
target_id: str,
target_url: str,
label: str,
) -> dict[str, Any]:
"""Ajoute une référence à une note et la retourne (avec son ``id``)."""
refs = _read_refs(username)
ref = {
"id": uuid.uuid4().hex,
"owner_type": "obsigate.note",
"owner_id": f"{vault}:{path}",
"owner_vault": vault,
"owner_path": path,
"target_app": target_app,
"target_type": target_type,
"target_id": target_id,
"target_url": target_url,
"label": label,
"domain": None,
"status": "ok",
"created_by": "user",
"created_at": datetime.now(UTC).isoformat(),
}
refs.append(ref)
_write_refs(username, refs)
return ref
def delete_ref(username: str, ref_id: str) -> dict[str, Any] | None:
"""Retire une référence ; retourne la référence retirée ou ``None``."""
refs = _read_refs(username)
kept = [r for r in refs if r.get("id") != ref_id]
if len(kept) == len(refs):
return None
removed = next(r for r in refs if r.get("id") == ref_id)
_write_refs(username, kept)
return removed
+10
View File
@@ -493,6 +493,12 @@ from backend.auth.scope import TokenScopeMiddleware
app.add_middleware(TokenScopeMiddleware) app.add_middleware(TokenScopeMiddleware)
# Fusion ACC-3.1 (#216) : renouvellement glissant du cookie SSO
# draco_session (inerte tant que DRACO_SESSION_SECRET n'est pas défini).
from backend.draco_session import DracoRenewalMiddleware
app.add_middleware(DracoRenewalMiddleware)
# Explicit same-origin CORS policy (#87 T8 — finit BUG-034). # Explicit same-origin CORS policy (#87 T8 — finit BUG-034).
# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`, # `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`,
# donc toute lecture cross-origin est refusée (défense explicite, plus # donc toute lecture cross-origin est refusée (défense explicite, plus
@@ -522,6 +528,7 @@ from backend.routers.config import _load_config
from backend.routers.config import router as config_router from backend.routers.config import router as config_router
from backend.routers.conflicts import router as conflicts_router from backend.routers.conflicts import router as conflicts_router
from backend.routers.duplicates import router as duplicates_router from backend.routers.duplicates import router as duplicates_router
from backend.routers.external_refs import router as external_refs_router
from backend.routers.file_chat import router as file_chat_router from backend.routers.file_chat import router as file_chat_router
from backend.routers.files_media import router as files_media_router from backend.routers.files_media import router as files_media_router
from backend.routers.files_read import router as files_read_router from backend.routers.files_read import router as files_read_router
@@ -533,6 +540,7 @@ from backend.routers.realtime import router as realtime_router
from backend.routers.scheduler import router as scheduler_router from backend.routers.scheduler import router as scheduler_router
from backend.routers.search import router as search_router from backend.routers.search import router as search_router
from backend.routers.sharing import router as sharing_router from backend.routers.sharing import router as sharing_router
from backend.routers.sso import router as sso_router
from backend.routers.vaults import router as vaults_router from backend.routers.vaults import router as vaults_router
from backend.routers.webhooks import router as webhooks_router from backend.routers.webhooks import router as webhooks_router
from backend.skills_routes import router as skills_router from backend.skills_routes import router as skills_router
@@ -543,6 +551,8 @@ app.include_router(bookslm_router)
app.include_router(skills_router) app.include_router(skills_router)
app.include_router(health_router) # ROADMAP #85 T1 — System / health app.include_router(health_router) # ROADMAP #85 T1 — System / health
app.include_router(history_router) # ROADMAP #85 T8 — History app.include_router(history_router) # ROADMAP #85 T8 — History
app.include_router(external_refs_router) # Fusion #215 — Références croisées (ACC-1.3)
app.include_router(sso_router) # Fusion #216 — Émettrice SSO draco_session (ACC-3.1)
app.include_router(realtime_router) # ROADMAP #85 T9 — SSE + collab WS app.include_router(realtime_router) # ROADMAP #85 T9 — SSE + collab WS
app.include_router(search_router) # ROADMAP #85 T5 — Search app.include_router(search_router) # ROADMAP #85 T5 — Search
app.include_router(backups_router) # ROADMAP #85 T4 — Backups app.include_router(backups_router) # ROADMAP #85 T4 — Backups
+4
View File
@@ -28,6 +28,8 @@ TAGS_METADATA: list[dict[str, str]] = [
{"name": "Vaults", "description": "List, add and remove vaults; per-vault display settings; attachment indexing."}, {"name": "Vaults", "description": "List, add and remove vaults; per-vault display settings; attachment indexing."},
{"name": "Search", "description": "Full-text and advanced search, tags, title suggestions and the link graph."}, {"name": "Search", "description": "Full-text and advanced search, tags, title suggestions and the link graph."},
{"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."}, {"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."},
{"name": "ExternalRefs", "description": "Cross-application references from a note to Flowdeck, TikTak, Kanbante or ObsiGate objects (Fusion program, ACC-1.3)."},
{"name": "SSO", "description": "draco_session single sign-on emitter: authorize entry point and jti revocation list for the other dracodev apps (Fusion program, ACC-3.1)."},
{"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."}, {"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."},
{"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."}, {"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."},
{"name": "Guide", "description": "Download the in-app user guide as Markdown or PDF (mirrors the help modal, FR/EN)."}, {"name": "Guide", "description": "Download the in-app user guide as Markdown or PDF (mirrors the help modal, FR/EN)."},
@@ -114,6 +116,8 @@ _TAG_RULES: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"^/api/graph"), "Search"), (re.compile(r"^/api/graph"), "Search"),
(re.compile(r"^/api/recent"), "Bookmarks"), (re.compile(r"^/api/recent"), "Bookmarks"),
(re.compile(r"^/api/bookmarks"), "Bookmarks"), (re.compile(r"^/api/bookmarks"), "Bookmarks"),
(re.compile(r"^/api/refs"), "ExternalRefs"),
(re.compile(r"^/api/sso"), "SSO"),
(re.compile(r"^/api/saved-searches"), "Bookmarks"), (re.compile(r"^/api/saved-searches"), "Bookmarks"),
(re.compile(r"^/api/vaults"), "Vaults"), (re.compile(r"^/api/vaults"), "Vaults"),
(re.compile(r"^/api/vault/"), "Vaults"), (re.compile(r"^/api/vault/"), "Vaults"),
+14 -3
View File
@@ -5,7 +5,10 @@ python-frontmatter==1.1.0
mistune==3.3.3 mistune==3.3.3
python-multipart==0.0.31 python-multipart==0.0.31
aiofiles==23.2.1 aiofiles==23.2.1
aiohttp>=3.9.0 # Plancher de sécurité : 3.14.3 est vulnérable à 8 CVE (dont
# CVE-2026-111063/111064 et GHSA-2g87-pp6c-29x5), toutes corrigées en
# 3.14.4 — pip-audit (CI, bloquant) exige de rester au-dessus.
aiohttp>=3.14.4
watchdog>=4.0.0 watchdog>=4.0.0
argon2-cffi>=23.1.0 argon2-cffi>=23.1.0
python-jose>=3.3.0 python-jose>=3.3.0
@@ -25,7 +28,9 @@ segno>=1.5.0
webauthn==2.6.0 webauthn==2.6.0
psutil>=5.9 psutil>=5.9
pywebpush>=2.3.0 pywebpush>=2.3.0
mcp==1.28.1 # 1.28.1 vulnérable (CVE-2026-59951, fix 1.30.0) — épinglé sur la
# première version corrigée de la même ligne 1.x.
mcp==1.30.0
# Plancher de sécurité (BUG-091, BUG-095) : pyjwt est une dépendance transitive # Plancher de sécurité (BUG-091, BUG-095) : pyjwt est une dépendance transitive
# (mcp). 2.12.x → PYSEC-2026-178 (fix 2.13.0) ; 2.13.0 → CVE-2026-102274 # (mcp). 2.12.x → PYSEC-2026-178 (fix 2.13.0) ; 2.13.0 → CVE-2026-102274
# (fix 2.14.0). pip-audit étant bloquant, on reste au-dessus du dernier correctif. # (fix 2.14.0). pip-audit étant bloquant, on reste au-dessus du dernier correctif.
@@ -36,7 +41,9 @@ xlrd==2.0.2
odfpy==1.4.1 odfpy==1.4.1
python-docx>=1.1 python-docx>=1.1
reportlab>=4.0 reportlab>=4.0
pillow>=10.0 # Plancher de sécurité : 12.3.0 est vulnérable à 8 CVE (dont
# CVE-2026-112895 et CVE-2026-112400), toutes corrigées en 13.0.0.
pillow>=13.0.0
# Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689) # Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689)
urllib3>=2.8.0 urllib3>=2.8.0
# Plancher de sécurité (CVE-2026-104874, fix 6.9.1) : multidict est transitive # Plancher de sécurité (CVE-2026-104874, fix 6.9.1) : multidict est transitive
@@ -44,3 +51,7 @@ urllib3>=2.8.0
# l'image du runner — sans plancher, pip répond « already satisfied » et # l'image du runner — sans plancher, pip répond « already satisfied » et
# n'aligne jamais (même piège que pypdf, BUG-093). # n'aligne jamais (même piège que pypdf, BUG-093).
multidict>=6.9.1 multidict>=6.9.1
# Plancher de sécurité (PYSEC-2026-4320, fix 2026.10.19) : certifi est
# transitive (httpx/requests) — sans plancher explicite, pip-audit
# signale la version héritée de l'environnement.
certifi>=2026.10.19
+84
View File
@@ -0,0 +1,84 @@
"""Endpoints des références croisées des notes — Fusion #215 (ACC-1.3).
Création par collage d'une URL profonde d'une autre application
(Flowdeck, TikTak, Kanbante, ObsiGate) — zéro couplage serveur :
l'URL est reconnue par les formes figées du contrat
``dracodev-commons/contrats/liens-profonds.md`` (voir
:mod:`backend.external_refs`), la référence est stockée chez la note
propriétaire et affichée dans sa section « Liens ».
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from backend.auth.middleware import check_vault_access, require_auth
from backend.external_refs import add_ref, delete_ref, get_ref, list_refs, parse_deep_link
from backend.schemas import ExternalRef, ExternalRefCreateRequest, ExternalRefsResponse
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["ExternalRefs"])
_FORMES_ACCEPTEES = (
"URL non reconnue — formes acceptées : TikTak ?task= ou ?list=, "
"ObsiGate #file=, Flowdeck /pages/ ou /collections/, "
"issue Gitea (carte Kanbante)"
)
@router.get("/api/refs", response_model=ExternalRefsResponse)
async def api_list_refs(
vault: str = Query(...),
path: str = Query(...),
current_user=Depends(require_auth),
):
"""Références croisées posées sur une note."""
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail="Accès au vault refusé")
username = current_user.get("username") or ""
return {"refs": list_refs(username, vault, path)}
@router.post("/api/refs", response_model=ExternalRef, status_code=201)
async def api_add_ref(
payload: ExternalRefCreateRequest,
current_user=Depends(require_auth),
):
"""Crée une référence sur une note à partir d'une URL profonde collée."""
if not check_vault_access(payload.vault, current_user):
raise HTTPException(status_code=403, detail="Accès au vault refusé")
parsed = parse_deep_link(payload.url)
if parsed is None:
raise HTTPException(status_code=422, detail=_FORMES_ACCEPTEES)
target_app, target_type, target_id, default_label = parsed
label = (payload.label or "").strip() or default_label
username = current_user.get("username") or ""
return add_ref(
username,
payload.vault,
payload.path,
target_app,
target_type,
target_id,
payload.url.strip(),
label,
)
@router.delete("/api/refs/{ref_id}", response_model=ExternalRef)
async def api_delete_ref(
ref_id: str,
current_user=Depends(require_auth),
):
"""Retire une référence d'une note."""
username = current_user.get("username") or ""
ref = get_ref(username, ref_id)
if ref is None:
raise HTTPException(status_code=404, detail="Lien introuvable")
if not check_vault_access(ref["owner_vault"], current_user):
raise HTTPException(status_code=403, detail="Accès au vault refusé")
removed = delete_ref(username, ref_id)
if removed is None: # course improbable : disparue entre get et delete
raise HTTPException(status_code=404, detail="Lien introuvable")
return removed
+43
View File
@@ -34,6 +34,8 @@ from backend.schemas import (
FileCreateRequest, FileCreateRequest,
FileCreateResponse, FileCreateResponse,
FileDeleteResponse, FileDeleteResponse,
FileDuplicateRequest,
FileDuplicateResponse,
FileMoveRequest, FileMoveRequest,
FileMoveResponse, FileMoveResponse,
FileRenameRequest, FileRenameRequest,
@@ -55,6 +57,9 @@ from backend.services.mutations import (
from backend.services.mutations import ( from backend.services.mutations import (
delete_file as service_delete_file, delete_file as service_delete_file,
) )
from backend.services.mutations import (
duplicate_path as service_duplicate_path,
)
from backend.services.mutations import ( from backend.services.mutations import (
edit_file as service_edit_file, edit_file as service_edit_file,
) )
@@ -743,3 +748,41 @@ async def api_file_move(
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}) await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type} return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
@router.post("/api/duplicate/{vault_name}", response_model=FileDuplicateResponse)
async def api_file_duplicate(
vault_name: str,
body: FileDuplicateRequest,
current_user=Depends(require_auth),
):
"""Duplicate (copy) a file or directory within the same vault."""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_duplicate_path(vault_name, body.source_path, body.destination_dir)
new_path_str = result["new_path"]
item_type = result["item_type"]
# Index: incrémental pour chaque fichier copié (évite un reload complet).
from backend.services.vaults import get_vault_root
root = get_vault_root(vault_name)
target = root / new_path_str
if item_type == "directory":
for fp in sorted(target.rglob("*")):
if fp.is_file():
await update_single_file(vault_name, str(fp.relative_to(root)).replace("\\", "/"))
else:
await update_single_file(vault_name, new_path_str)
await sse_manager.broadcast("item_duplicated", {
"vault": vault_name,
"source_path": result["source_path"],
"new_path": new_path_str,
"item_type": item_type,
})
await dispatch_webhooks(
"file_created" if item_type == "file" else "directory_created",
{"vault": vault_name, "path": new_path_str},
)
return {"success": True, "source_path": result["source_path"], "new_path": new_path_str, "item_type": item_type}
+44
View File
@@ -0,0 +1,44 @@
# backend/routers/sso.py
# Fusion ACC-3.1 (#216) — Points d'entrée SSO de l'émettrice.
#
# GET /sso/authorize?return_to=<url> : point d'entrée des autres
# applications — valide le return_to (liste blanche *.dracodev.net,
# anti open-redirect) puis redirige vers la page de connexion
# d'ObsiGate en conservant le paramètre ; après connexion, le
# frontend repart vers l'application demanderesse (le cookie
# draco_session posé à la connexion couvre tout le domaine parent).
#
# GET /api/sso/revocations : liste des jti révoqués, réservée aux
# autres applications (clé machine DRACO_SSO_MACHINE_KEY, en-tête
# X-Draco-Machine-Key) qui la mettent en cache 60 s (design §5.2).
from __future__ import annotations
import os
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from backend import draco_session
router = APIRouter(tags=["SSO"])
@router.get("/sso/authorize")
async def sso_authorize(return_to: str = ""):
target = draco_session.validate_return_to(return_to)
if target:
return RedirectResponse(
url=f"/?return_to={quote(target, safe='')}", status_code=302)
return RedirectResponse(url="/", status_code=302)
@router.get("/api/sso/revocations")
async def sso_revocations(request: Request):
machine_key = os.environ.get("DRACO_SSO_MACHINE_KEY", "")
if not machine_key:
raise HTTPException(404, "Non configuré")
if request.headers.get("X-Draco-Machine-Key") != machine_key:
raise HTTPException(401, "Clé machine invalide")
return {"revoked": draco_session.revoked_list()}
+52
View File
@@ -580,6 +580,20 @@ class FileMoveResponse(BaseModel):
success: bool = Field(description="Whether move succeeded") success: bool = Field(description="Whether move succeeded")
old_path: str = Field(description="Original path") old_path: str = Field(description="Original path")
new_path: str = Field(description="New path after move") new_path: str = Field(description="New path after move")
class FileDuplicateRequest(BaseModel):
"""Request to duplicate (copy) a file or directory."""
source_path: str = Field(description="Relative path of the item to duplicate")
destination_dir: str = Field(default="", description="Target directory relative path (empty = source parent)")
class FileDuplicateResponse(BaseModel):
"""Response after duplicating a file or directory."""
success: bool = Field(description="Whether duplication succeeded")
source_path: str = Field(description="Original path")
new_path: str = Field(description="Path of the created copy")
item_type: str = Field(description="Type of item moved: 'file' or 'directory'") item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
@@ -605,6 +619,44 @@ class BookmarkToggleRequest(BaseModel):
title: str | None = None title: str | None = None
class ExternalRef(BaseModel):
"""Référence croisée d'une note vers un objet d'une autre application.
Modèle figé par dracodev-commons (contrats/external_ref.schema.json,
v0.1.0) — Fusion #215 (ACC-1.3).
"""
id: str
owner_type: str = Field(description="Toujours 'obsigate.note' côté ObsiGate")
owner_id: str = Field(description="'<vault>:<chemin>' de la note propriétaire")
owner_vault: str
owner_path: str
target_app: str = Field(description="obsigate | flowdeck | tiktak | kanbante")
target_type: str = Field(description="note | project | page | task | list | card")
target_id: str
target_url: str = Field(description="URL profonde selon contrats/liens-profonds.md")
label: str = Field(description="Instantané d'affichage du titre de la cible")
domain: str | None = None
status: str = Field(default="ok", description="ok | target_missing")
created_by: str = Field(default="user")
created_at: str
class ExternalRefCreateRequest(BaseModel):
"""Création d'une référence par collage d'URL profonde."""
vault: str
path: str
url: str
label: str | None = None
class ExternalRefsResponse(BaseModel):
"""Liste des références d'une note."""
refs: list[ExternalRef]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Search / suggest / graph (#85 — extrait de backend.main, inchangé) # Search / suggest / graph (#85 — extrait de backend.main, inchangé)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+74
View File
@@ -1534,6 +1534,80 @@ def move_path(vault_name: str, source_path: str, destination_dir: str = "") -> d
"item_type": item_type, "item_type": item_type,
} }
def duplicate_path(vault_name: str, source_path: str, destination_dir: str = "") -> dict[str, Any]:
"""Duplicate a file or directory (copy) into *destination_dir* (same vault).
The copy is named ``<stem> copie<ext>`` (then ``copie 2``, ``copie 3``… on
collision). Directories are copied recursively.
Raises:
ServiceError: ``not_found`` (404), ``read_only`` (403),
``permission_denied`` (403), ``invalid_arguments`` (400) when the
destination is inside the source directory.
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
source = resolve_safe_path(root, source_path)
if not source.exists():
raise ServiceError(
f"Source not found: {source_path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": source_path},
)
is_directory = source.is_dir()
dest_clean = (destination_dir or "").strip("/")
if dest_clean:
dest_parent = resolve_safe_path(root, dest_clean)
if not dest_parent.exists() or not dest_parent.is_dir():
raise ServiceError(
f"Destination directory not found: {destination_dir}",
code="not_found",
status=404,
details={"vault": vault_name, "path": destination_dir},
)
else:
dest_parent = root
if is_directory and str(dest_parent.resolve()) in (str(source.resolve()), str(source.resolve()) + os.sep, str(source.resolve()) + "/"):
raise ServiceError(
"Cannot duplicate a directory inside itself",
code="invalid_arguments",
status=400,
details={"vault": vault_name, "path": source_path},
)
ext = "" if is_directory else source.suffix
base = dest_parent / (source.stem + " copie" + ext)
target = base
n = 2
while target.exists():
target = base.parent / (source.stem + " copie " + str(n) + ext)
n += 1
try:
if is_directory:
shutil.copytree(source, target)
else:
shutil.copy2(source, target)
except PermissionError as e:
raise ServiceError("Permission denied: cannot duplicate item", code="permission_denied", status=403) from e
new_rel = _rel(root, target)
item_type = "directory" if is_directory else "file"
logger.info(f"Item duplicated: {vault_name}/{source_path} -> {new_rel}")
return {
"success": True,
"vault": vault_name,
"source_path": _rel(root, source),
"new_path": new_rel,
"item_type": item_type,
}
# ── D3. Find & replace ───────────────────────────────────────────────────── # ── D3. Find & replace ─────────────────────────────────────────────────────
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]] [[package]]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.68.5" version = "2.72.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"env_logger", "env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.68.5" version = "2.72.0"
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 -1
View File
@@ -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.68.5", "version": "2.72.0",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "frontendDist": "../frontend",
+5 -1
View File
@@ -222,7 +222,9 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-112* | E2E local : `pwsh -File scripts/e2e-server.ps1 2>&1 \| head -n 20` ne se termine **jamais** — la commande semble bloquée des dizaines de minutes alors que le serveur est prêt en ~7 s | 🟢 corrigé | P1 | 🧪 E2E local / Windows | IA | `scripts/e2e-server.ps1` (`start`) | `pwsh -NoProfile -File scripts/e2e-server.ps1 start 2>&1 \| head -n 20` → pipeline ouvert ~500 s+ ; se termine en <2 s dès qu'uvicorn est tué (problème d'EOF du pipe, pas de lenteur du démarrage) | `Start-Process` laissait uvicorn hériter des handles stdout/stderr du pwsh parent → le pipe `\| head` reste ouvert tant que le serveur vit → `head` attend un EOF qui n'arrive jamais. Remplacé par `Win32_Process.Create` (WMI) : processus détaché sans handle hérité ; le processus WMI n'héritant pas de l'environnement, les `OBSIGATE_*` sont passés via `set "NOM=valeur"` dans `cmd /c` (les `""` doublés faisaient échouer le quote-stripping de cmd : forme sans guillemets extérieurs) | Vérifié : commande exacte de l'utilisateur → `real 0m6,883s`, `[OK] READY v2.67.2`, serveur toujours up (2 vaults, 137 fichiers indexés), `stop`/`status` OK, syntaxe PS validée, aucun orphelin | | *BUG-112* | E2E local : `pwsh -File scripts/e2e-server.ps1 2>&1 \| head -n 20` ne se termine **jamais** — la commande semble bloquée des dizaines de minutes alors que le serveur est prêt en ~7 s | 🟢 corrigé | P1 | 🧪 E2E local / Windows | IA | `scripts/e2e-server.ps1` (`start`) | `pwsh -NoProfile -File scripts/e2e-server.ps1 start 2>&1 \| head -n 20` → pipeline ouvert ~500 s+ ; se termine en <2 s dès qu'uvicorn est tué (problème d'EOF du pipe, pas de lenteur du démarrage) | `Start-Process` laissait uvicorn hériter des handles stdout/stderr du pwsh parent → le pipe `\| head` reste ouvert tant que le serveur vit → `head` attend un EOF qui n'arrive jamais. Remplacé par `Win32_Process.Create` (WMI) : processus détaché sans handle hérité ; le processus WMI n'héritant pas de l'environnement, les `OBSIGATE_*` sont passés via `set "NOM=valeur"` dans `cmd /c` (les `""` doublés faisaient échouer le quote-stripping de cmd : forme sans guillemets extérieurs) | Vérifié : commande exacte de l'utilisateur → `real 0m6,883s`, `[OK] READY v2.67.2`, serveur toujours up (2 vaults, 137 fichiers indexés), `stop`/`status` OK, syntaxe PS validée, aucun orphelin |
| *BUG-113* | Perf : suppression d'un répertoire dans la vault home-user très lente — le temps croît avec la vault entière, pas avec le dossier supprimé | 🟢 corrigé | P1 | ⚙️ backend / perf | IA | `backend/routers/files_write.py` (`api_directory_delete`), `backend/indexer.py` (`remove_directory_from_index` nouveau) | Supprimer un gros répertoire du home-user → l'API met des secondes (re-scan complet de la vault + rebuild de l'index inversé global) | `DELETE /api/directory/{vault}` appelait `reload_single_vault` après le `rmtree` : re-scan de TOUTE la vault + `init_inverted_index()` global. Remplacé par `remove_directory_from_index` : retire du sous-arbre uniquement (fichiers + entrées dossiers de `path_index`, notification incrémentale de l'index inversé et sémantique via le hook existant) | Mesuré sur l'instance E2E : 60 fichiers supprimés → **63 ms** vs **668 ms** avant (~10×) ; aucun fantôme dans `/api/vault/{v}/paths` ni dans la recherche plein-texte après suppression (`test_delete_directory_subtree_clears_index`) ; 232 tests verts, ruff 0, mypy 0 | | *BUG-113* | Perf : suppression d'un répertoire dans la vault home-user très lente — le temps croît avec la vault entière, pas avec le dossier supprimé | 🟢 corrigé | P1 | ⚙️ backend / perf | IA | `backend/routers/files_write.py` (`api_directory_delete`), `backend/indexer.py` (`remove_directory_from_index` nouveau) | Supprimer un gros répertoire du home-user → l'API met des secondes (re-scan complet de la vault + rebuild de l'index inversé global) | `DELETE /api/directory/{vault}` appelait `reload_single_vault` après le `rmtree` : re-scan de TOUTE la vault + `init_inverted_index()` global. Remplacé par `remove_directory_from_index` : retire du sous-arbre uniquement (fichiers + entrées dossiers de `path_index`, notification incrémentale de l'index inversé et sémantique via le hook existant) | Mesuré sur l'instance E2E : 60 fichiers supprimés → **63 ms** vs **668 ms** avant (~10×) ; aucun fantôme dans `/api/vault/{v}/paths` ni dans la recherche plein-texte après suppression (`test_delete_directory_subtree_clears_index`) ; 232 tests verts, ruff 0, mypy 0 |
| *BUG-114* | Popup « currentPath is not defined » après suppression (ou renommage) d'un répertoire | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ui.js` (`confirmDeleteDirectory`, renommage inline) | Ouvrir un fichier d'un dossier, clic droit → Supprimer le dossier (ou Renommer) → toast d'erreur `currentPath is not defined` alors que l'opération réussit | Les branches répertoire testaient `currentPath` nu (non déclaré dans ui.js — la variable vit dans `state`) au lieu de `state.currentPath` : `ReferenceError` dans le `try`, avalée par le `catch` → toast. Trois usages corrigés (startsWith ×2, slice ×1) | Vérifié en Playwright (instance 2029, flux réel clic droit → Supprimer sur un dossier contenant le fichier ouvert) : toast « Dossier supprimé (1 fichiers) », 0 fantôme dans /api/vault/{v}/paths, 0 erreur JS ; validate-imports 42 modules, unit.test.mjs 13/13 | | *BUG-114* | Popup « currentPath is not defined » après suppression (ou renommage) d'un répertoire | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ui.js` (`confirmDeleteDirectory`, renommage inline) | Ouvrir un fichier d'un dossier, clic droit → Supprimer le dossier (ou Renommer) → toast d'erreur `currentPath is not defined` alors que l'opération réussit | Les branches répertoire testaient `currentPath` nu (non déclaré dans ui.js — la variable vit dans `state`) au lieu de `state.currentPath` : `ReferenceError` dans le `try`, avalée par le `catch` → toast. Trois usages corrigés (startsWith ×2, slice ×1) | Vérifié en Playwright (instance 2029, flux réel clic droit → Supprimer sur un dossier contenant le fichier ouvert) : toast « Dossier supprimé (1 fichiers) », 0 fantôme dans /api/vault/{v}/paths, 0 erreur JS ; validate-imports 42 modules, unit.test.mjs 13/13 |
| *BUG-115* | Éditeur Forge : cases à cocher collées au texte dans l'aperçu, aperçu qui flashe à la frappe, menu « dimensions du tableau » coupé hors écran sur un document court | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (CSS aperçu, `renderPreview`, `showTableForm`, `showLangPicker`) | Ouvrir un `.md` avec `- [ ]`, activer l'aperçu → texte collé au checkbox + flash « Rendering... » ; `/table` sur un document d'une ligne → forme partiellement visible | (1) règle `.task-list-item input[type=checkbox] { margin-right: 6px }` ajoutée au `<style>` de l'aperçu (miroir `style.css:2303`) ; (2) `renderPreview` ne remplace plus le contenu par le spinner (affiché seulement si vide) + flag `_previewDirty` pour rejouer la dernière frappe ; (3) `.inline-form`/`.lang-picker` en `position: fixed` (coordonnées viewport) avec clamp bas/droite comme le menu slash + `stopPropagation` sur les clics qi/slash (le listener document refermait la forme) | Vérifié en Playwright (instance E2E) : marginRight 6 px + `list-style: none`, MutationObserver : aucun « Rendering... » pendant 14 frappes et dernier état rendu, forme table `bottom 193 < vh 900` sur document 1 ligne + insertion OK | ### TODOs techniques (améliorations / nouvelles tâches) | *BUG-115* | Éditeur Forge : cases à cocher collées au texte dans l'aperçu, aperçu qui flashe à la frappe, menu « dimensions du tableau » coupé hors écran sur un document court | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (CSS aperçu, `renderPreview`, `showTableForm`, `showLangPicker`) | Ouvrir un `.md` avec `- [ ]`, activer l'aperçu → texte collé au checkbox + flash « Rendering... » ; `/table` sur un document d'une ligne → forme partiellement visible | (1) règle `.task-list-item input[type=checkbox] { margin-right: 6px }` ajoutée au `<style>` de l'aperçu (miroir `style.css:2303`) ; (2) `renderPreview` ne remplace plus le contenu par le spinner (affiché seulement si vide) + flag `_previewDirty` pour rejouer la dernière frappe ; (3) `.inline-form`/`.lang-picker` en `position: fixed` (coordonnées viewport) avec clamp bas/droite comme le menu slash + `stopPropagation` sur les clics qi/slash (le listener document refermait la forme) | Vérifié en Playwright (instance E2E) : marginRight 6 px + `list-style: none`, MutationObserver : aucun « Rendering... » pendant 14 frappes et dernier état rendu, forme table `bottom 193 < vh 900` sur document 1 ligne + insertion OK |
| *BUG-116* | Éditeur Forge : après insertion d'un tableau (ex. 4 lignes), la textarea reste d'une seule ligne au lieu de s'agrandir | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (`insertAtCursor`, `wrapSelection`) | Insérer un tableau via le menu : le contenu contient bien les nouvelles lignes mais la hauteur de la textarea ne change pas | Les mutations programmatiques de `ta.value` ne déclenchent pas l'événement `input` — seul handler qui rappelait `autoHeight()`/`updateLineNumbers()` (les handlers clavier, eux, les appelaient à la main). Ajout des deux appels dans `insertAtCursor` et `wrapSelection`, les deux points de passage de toutes les insertions (tableau, code, mermaid, checklist, gras multi-ligne) | Vérifié en Playwright sur l'instance E2E (qi → Table, 4 lignes, sur doc d'une ligne) : hauteur 51 → 189 px, 8 lignes rendues, gutter aligné | ### TODOs techniques (améliorations / nouvelles tâches)
| *BUG-117* | Drag & drop des fichiers dans la sidebar : le déplacement d'un répertoire à un autre ne fait plus rien, sans message | 🟢 corrigé | P1 | 📱 frontend / sidebar | IA | `frontend/js/dragdrop.js` (`onDrop`) | Glisser un fichier sur un dossier : aucune requête `/api/move`, aucun toast | La détection `isExternalFiles` dans `onDrop` (`items.length > 0`, ajoutée par #89) comptait aussi les formats string du drag interne (`tree:vault/path/type` posés par `setData` au `dragstart`) : tout déplacement partait dans la branche upload où rien ne passe le filtre `kind === 'file'` → drop avalé silencieusement. Corrigé à la racine : `dragState` (interne) prioritaire + items réellement fichiers. Livré avec #201 : menu contextuel Déplacer/Dupliquer réutilisant `promptTargetFolder`, emplacement actuel pré-sélectionné | Vérifié Playwright instance E2E : glisser `note1.md` → `d1` = toast « Moved file to d1 » + chemin déplacé ; 4 tests `TestDuplicateApi` |
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes | | # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|
@@ -351,6 +353,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-10-14 | BUG-113 | Perf | `backend/indexer.py`, `backend/routers/files_write.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-113 — suppression de répertoire ~10× plus rapide.** Constat : dans le home-user, supprimer un répertoire prenait des secondes — le temps croissait avec la **vault entière**, pas avec le dossier supprimé. Cause : `DELETE /api/directory/{vault}` appelait `reload_single_vault` après le `rmtree` : re-scan complet de la vault + rebuild de l'index inversé **global** (toutes les vaults). Correctif : `remove_directory_from_index` (indexer.py) retire uniquement les fichiers du sous-arbre (files, tags, `_file_lookup`, `path_index` avec purge des dossiers fantômes) et notifie l'index inversé + sémantique **incrémentalement** via le hook existant — le même chemin que le watcher pour une suppression de fichier. Mesuré sur l'instance E2E : 60 fichiers supprimés → **63 ms** vs **668 ms** avant. Tests : `test_delete_directory_subtree_clears_index` (aucun fantôme dans `/api/vault/{v}/paths` ni dans la recherche plein-texte après suppression). Vérifié : 232 passed / 1 skip (api_main + indexer_advanced + tools_mutations + search), ruff 0, mypy 0 | 🟢 corrigé (en attente vérif utilisateur) | | 2026-10-14 | BUG-113 | Perf | `backend/indexer.py`, `backend/routers/files_write.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-113 — suppression de répertoire ~10× plus rapide.** Constat : dans le home-user, supprimer un répertoire prenait des secondes — le temps croissait avec la **vault entière**, pas avec le dossier supprimé. Cause : `DELETE /api/directory/{vault}` appelait `reload_single_vault` après le `rmtree` : re-scan complet de la vault + rebuild de l'index inversé **global** (toutes les vaults). Correctif : `remove_directory_from_index` (indexer.py) retire uniquement les fichiers du sous-arbre (files, tags, `_file_lookup`, `path_index` avec purge des dossiers fantômes) et notifie l'index inversé + sémantique **incrémentalement** via le hook existant — le même chemin que le watcher pour une suppression de fichier. Mesuré sur l'instance E2E : 60 fichiers supprimés → **63 ms** vs **668 ms** avant. Tests : `test_delete_directory_subtree_clears_index` (aucun fantôme dans `/api/vault/{v}/paths` ni dans la recherche plein-texte après suppression). Vérifié : 232 passed / 1 skip (api_main + indexer_advanced + tools_mutations + search), ruff 0, mypy 0 | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-14 | BUG-114 | Correction | `frontend/js/ui.js`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-114 — popup « currentPath is not defined » après suppression/renommage de répertoire.** Les branches répertoire de `confirmDeleteDirectory` (ligne ~1054) et du renommage inline (lignes ~967-968) référençaient `currentPath` nu au lieu de `state.currentPath` (ui.js ne déclare pas `currentPath`, contrairement à app.js) : `ReferenceError` levée dans le `try`, avalée par le `catch`, affichée en toast d'erreur alors que l'opération (DELETE/PATCH) avait réussi. Trois usages corrigés. Vérifié en Playwright sur l'instance E2E (flux réel clic droit → Supprimer) : toast de succès, 0 fantôme dans paths, 0 erreur JS ; validate-imports 42 modules / 373 exports, unit.test.mjs 13/13 | 🟢 corrigé (en attente vérif utilisateur) | | 2026-10-14 | BUG-114 | Correction | `frontend/js/ui.js`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-114 — popup « currentPath is not defined » après suppression/renommage de répertoire.** Les branches répertoire de `confirmDeleteDirectory` (ligne ~1054) et du renommage inline (lignes ~967-968) référençaient `currentPath` nu au lieu de `state.currentPath` (ui.js ne déclare pas `currentPath`, contrairement à app.js) : `ReferenceError` levée dans le `try`, avalée par le `catch`, affichée en toast d'erreur alors que l'opération (DELETE/PATCH) avait réussi. Trois usages corrigés. Vérifié en Playwright sur l'instance E2E (flux réel clic droit → Supprimer) : toast de succès, 0 fantôme dans paths, 0 erreur JS ; validate-imports 42 modules / 373 exports, unit.test.mjs 13/13 | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-14 | BUG-115 | Correction | `frontend/editor-poc.html`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-115 — trois ajustements de l'éditeur Forge (aperçu + menus).** (1) Cases à cocher collées au texte : le rendu serveur (mistune task_lists) ne met pas d'espace après l'`<input>` et l'`<style>` de `editor-poc.html` n'avait pas l'équivalent de la règle `.md-content .task-list-item` de `style.css` → deux règles ajoutées (margin-right 6 px, list-style none). (2) Flash de l'aperçu : `renderPreview` écrasait le contenu par « Rendering... » à chaque frappe (debounce 500 ms) — remplacé par un rafraîchissement silencieux (l'ancien rendu reste affiché, spinner seulement au premier rendu) + `_previewDirty` qui rejoue la dernière frappe tombée pendant un fetch (état fini correct, sans clignotement). (3) Menu « dimensions du tableau » coupé sur un document d'une ligne : `.inline-form`/`.lang-picker` en `position: absolute` recevaient des coordonnées **viewport** (`wr.left+pt.x`) → double décalage et clipping par `.editor-pane { overflow: hidden }` ; passés en `position: fixed` avec clamp viewport (convention du menu slash, déjà dans le fichier) ; au passage le clic sur un item qi/slash bullait jusqu'au listener document qui refermait la forme aussitôt ouverte → `stopPropagation`. Vérifié en Playwright sur l'instance E2E (serveur réel) : checkbox `marginRight: 6px`, aucun « Rendering... » observé pendant la frappe et dernier état rendu, forme table entièrement visible (`bottom 193 < vh 900`) et insertion OK. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-10-14 | BUG-115 | Correction | `frontend/editor-poc.html`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-115 — trois ajustements de l'éditeur Forge (aperçu + menus).** (1) Cases à cocher collées au texte : le rendu serveur (mistune task_lists) ne met pas d'espace après l'`<input>` et l'`<style>` de `editor-poc.html` n'avait pas l'équivalent de la règle `.md-content .task-list-item` de `style.css` → deux règles ajoutées (margin-right 6 px, list-style none). (2) Flash de l'aperçu : `renderPreview` écrasait le contenu par « Rendering... » à chaque frappe (debounce 500 ms) — remplacé par un rafraîchissement silencieux (l'ancien rendu reste affiché, spinner seulement au premier rendu) + `_previewDirty` qui rejoue la dernière frappe tombée pendant un fetch (état fini correct, sans clignotement). (3) Menu « dimensions du tableau » coupé sur un document d'une ligne : `.inline-form`/`.lang-picker` en `position: absolute` recevaient des coordonnées **viewport** (`wr.left+pt.x`) → double décalage et clipping par `.editor-pane { overflow: hidden }` ; passés en `position: fixed` avec clamp viewport (convention du menu slash, déjà dans le fichier) ; au passage le clic sur un item qi/slash bullait jusqu'au listener document qui refermait la forme aussitôt ouverte → `stopPropagation`. Vérifié en Playwright sur l'instance E2E (serveur réel) : checkbox `marginRight: 6px`, aucun « Rendering... » observé pendant la frappe et dernier état rendu, forme table entièrement visible (`bottom 193 < vh 900`) et insertion OK. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-14 | BUG-116 | Correction | `frontend/editor-poc.html`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-116 — la textarea Forge ne grandissait pas après une insertion programmatique.** Après un tableau de 4 lignes inséré via le menu, l'éditeur restait d'une ligne : le contenu était bien multiligne mais `autoHeight()` n'était rappelé que par l'événement `input`, que `ta.value = …` ne déclenche pas. Les handlers clavier appelaient `autoHeight(); updateLineNumbers();` à la main ; `insertAtCursor` et `wrapSelection` — les deux fonctions par lesquelles passent toutes les insertions (tableau, bloc de code, templates mermaid, checklist, mise en forme) — oubliaient les deux appels. Corrigé à la source dans les deux fonctions plutôt que chez chaque appelant. Vérifié en Playwright (instance E2E, chemin réel qi → Table, 4 lignes sur document d'une ligne) : hauteur 51 → 189 px, gutter = nb de lignes | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-15 | BUG-117 + #201 | Correction + feature | `frontend/js/dragdrop.js`, `frontend/js/ui.js`, `frontend/js/upload.js`, `frontend/locales/fr.json`, `frontend/locales/en.json`, `backend/services/mutations.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **BUG-117 — le drag & drop interne de la sidebar était mort en silence.** `onDrop` classait tout dépôt « externe » dès que `dataTransfer.items` était non vide — or un glisser interne dépose 4 formats string (`tree:*`) : chaque déplacement partait dans la branche upload où le filtre `kind === 'file'` ne trouvait rien — aucun appel API, aucun toast. Détection corrigée (vrais fichiers seulement, `dragState` prioritaire). Livré avec **#201 : menu contextuel Déplacer/Dupliquer** (fichiers et dossiers), modal `promptTargetFolder` réutilisé (+ `opts`, emplacement actuel pré-sélectionné), endpoint `POST /api/duplicate/{vault}` (`duplicate_path`, index incrémental, SSE/webhooks, refus inside-self), i18n FR/EN, 4 tests `TestDuplicateApi` + validation Playwright E2E complète | 🟢 corrigé/livré (en attente vérif utilisateur) |
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres « * » écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes « * » → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) | | 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres « * » écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes « * » → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
--- ---
+78 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap # ObsiGate — Roadmap
> **Version :** 2.68.5 | **Dernière mise à jour :** 2026-10-14 > **Version :** 2.72.0 | **Dernière mise à jour :** 2026-10-20
> **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)**
@@ -30,6 +30,71 @@
## 🔵 En cours ## 🔵 En cours
### 215. Fusion bloc 1 — barre d'apps commune et références croisées des notes
- **Effort :** ~1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-20 | **Statut :** ✅ livré le 2026-10-20 (2.70.0)
- **Fiche :** [features/fusion-bloc1-barre-refs-215.md](./features/fusion-bloc1-barre-refs-215.md)
- **Description :** première tranche du programme **Fusion** (accès centralisé
ObsiGate / Flowdeck / TikTak / Kanbante — dépôt programme
`Projets/fusion-trois-apps`, épopée ACC-1 ; contrats versionnés dans
`Projets/dracodev-commons` v0.1.0). ID 215 choisi hors des plages déjà
utilisées ou proposées (#191–#201 livrées/Assistant V2, #202–#214
proposées par ASSISTANT-V2.1). Issues Gitea ObsiGate #5 (ACC-1.1),
#6 (ACC-1.2), #7 (ACC-1.3).
- **Sous-tâches :**
- [x] Barre d'apps commune vendangée du snippet figé de commons
(`frontend/draco-barre.css/.js`, markup dans `index.html`) :
ObsiGate marquée active, navigation vers les 4 apps, domaine mémorisé
- [x] Backend `external_ref` : stockage JSON par utilisateur
(`backend/external_refs.py`), endpoints `GET/POST /api/refs` et
`DELETE /api/refs/{id}` (schémas + tag OpenAPI), parseur d'URL
profondes conforme au contrat figé
- [x] Section « Liens » dans la vue d'une note (module
`frontend/js/draco-liens.js`, point d'extension
`obsigate:file-rendered` émis par `renderFile`)
- [x] Notes projetées TikTak reconnues (frontmatter `tiktak_id`) :
bandeau « Tâche TikTak projetée » + « Ouvrir dans TikTak »
- [x] i18n FR/EN (clés `draco.links.*`), tests `test_external_refs.py`
### 216. Fusion ACC-3.1 — émettrice SSO `draco_session`
- **Effort :** ~1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.71.0)
- **Fiche :** [features/fusion-acc31-sso-draco-session-216.md](./features/fusion-acc31-sso-draco-session-216.md)
- **Description :** couche 2 (identité) du programme **Fusion** — décision C2
de Bruno : cookie de session partagé sous `.dracodev.net` (architecture
v2.0 §5.2, option 2a). ObsiGate est l'unique émettrice ; les autres apps
valideront localement (ACC-3.2). Issue Gitea ObsiGate #8 ; épopée
Projets/fusion-trois-apps#7.
- **Sous-tâches :**
- [x] Module `backend/draco_session.py` : JWS HS256, claims minimaux
(sub, name, iat, exp 12 h, jti — aucun rôle), rotation par
`DRACO_SESSION_SECRET_PREV`, inerte sans `DRACO_SESSION_SECRET`
- [x] Émission du cookie à chaque `_issue_tokens` (mot de passe ou MFA)
- [x] Renouvellement glissant (middleware ASGI `DracoRenewalMiddleware`,
fenêtre 2 h, conditionné au cookie d'accès natif valide)
- [x] Déconnexion globale : révocation du jti (`data/draco_revoked.json`)
+ effacement du cookie ; `GET /api/sso/revocations` sous clé machine
- [x] `GET /sso/authorize` : `return_to` en liste blanche
`*.dracodev.net` (anti open-redirect), retour post-connexion
re-validé côté frontend (`auth.js`)
- [x] Tag OpenAPI « SSO », tests `test_draco_session.py` (22)
### 217. Fusion ACC-3.2 — validation locale du jeton `draco_session`
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.72.0)
- **Fiche :** [features/fusion-acc32-validation-locale-217.md](./features/fusion-acc32-validation-locale-217.md)
- **Description :** pendant consommateur de #216 — `get_current_user`
accepte le cookie partagé à défaut de jeton natif valide ; le `sub`
étant l'identifiant ObsiGate, la correspondance vers le compte local
est directe (compte actif exigé, garde-fou BUG-028 appliqué).
Issue Gitea ObsiGate #9 ; épopée Projets/fusion-trois-apps#7.
- **Sous-tâches :**
- [x] `draco_session.user_for_draco()` : résolution par id, `_draco_sub`
- [x] Repli `_draco_fallback()` sur toutes les sorties « non
authentifié » du chemin natif (`backend/auth/middleware.py`)
- [x] Tests `test_draco_session.py` (+4 : cookie seul, révoqué,
sub inconnu, inerte sans secret)
### 196. Partage dirigé entre utilisateurs (document → utilisateur) ### 196. Partage dirigé entre utilisateurs (document → utilisateur)
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10 - **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10
@@ -155,6 +220,18 @@
compte de synchro + clé limitée (#199) + webhook filtré (#198) + recette `curl` compte de synchro + clé limitée (#199) + webhook filtré (#198) + recette `curl`
d'acceptation. **Reste à exécuter sur l'hôte** (suivre le runbook, puis fermer Gitea #1). d'acceptation. **Reste à exécuter sur l'hôte** (suivre le runbook, puis fermer Gitea #1).
### 201. Menu contextuel « Déplacer » / « Dupliquer » + réparation du drag & drop interne (sidebar, BUG-117)
- **Effort :** 0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-15 | **Statut :** ✅ livré le 2026-10-15
- **Description :** déplacement/duplication fichiers et répertoires via le menu contextuel
(modal `promptTargetFolder` réutilisé et harmonisé, emplacement actuel pré-sélectionné,
cross-vault refusé proprement) ; endpoint `POST /api/duplicate/{vault}` (`duplicate_path` :
copie `X copie.ext`, collisions numérotées, refus de dupliquer un dossier dans lui-même,
index incrémental, SSE + webhooks) ; drag & drop interne réparé (le drop était avalé en
silence par la détection `isExternalFiles` qui comptait les formats string `tree:*` du
glisser interne) ; i18n FR/EN ; 4 tests `TestDuplicateApi` + validation Playwright E2E.
--- ---
### 77. Application Desktop native — Tauri (Windows / Linux / macOS) ### 77. Application Desktop native — Tauri (Windows / Linux / macOS)
@@ -0,0 +1,67 @@
# #216 — Émettrice SSO `draco_session` (Fusion ACC-3.1)
Statut : ✅ livré le 2026-10-21 (2.71.0)
## Contexte
Programme Fusion (accès centralisé ObsiGate / Flowdeck / TikTak /
Kanbante), couche 2 — identité. Décision **C2** de Bruno (2026-10-20) :
cookie de session partagé `draco_session` sous `.dracodev.net`
(architecture v2.0 §5.2, option 2a). ObsiGate est l'**unique
émettrice** ; les autres applications valideront le jeton localement
(ACC-3.2, leurs tickets respectifs) et redirigeront leur connexion
vers `GET /sso/authorize`.
## Ce qui est livré
- **`backend/draco_session.py`** — émission et vérification du JWS
(HS256, jose) : claims minimaux `sub` (id utilisateur ObsiGate),
`name`, `iat`, `exp` (**12 h**), `jti` ; **aucun rôle** dans le
jeton. Rotation : `DRACO_SESSION_SECRET_PREV` accepté en
vérification pendant la fenêtre de bascule.
- **Émission à la connexion** : `_issue_tokens` (auth/router.py) pose
le cookie à chaque émission de jetons natifs (mot de passe ou MFA) —
attributs `Domain=.dracodev.net; Path=/; HttpOnly; Secure (si la
requête est en https, via `is_secure_cookies`); SameSite=Lax`.
- **Renouvellement glissant** : `DracoRenewalMiddleware` (ASGI pur,
aucun tamponnage des flux SSE/WS) — si la requête porte un cookie
d'accès natif valide et que le cookie draco est absent, invalide ou
à moins de 2 h de son expiration, un cookie frais est ajouté à la
réponse.
- **Déconnexion globale** : le logout révoque le `jti` draco
(en plus des jetons natifs) et efface le cookie du domaine parent.
Révocations persistées dans `data/draco_revoked.json`
(jti → exp, purge des expirés), exposées aux autres apps par
**`GET /api/sso/revocations`** protégé par la clé machine
`DRACO_SSO_MACHINE_KEY` (en-tête `X-Draco-Machine-Key`, 404 si la
clé n'est pas configurée) — cache 60 s côté consommatrices.
- **`GET /sso/authorize?return_to=`** : valide le `return_to`
(https + `dracodev.net` ou sous-domaine, liste blanche stricte
anti open-redirect) puis redirige vers la connexion ObsiGate ;
après connexion, `frontend/js/auth.js` repart vers l'application
demanderesse (re-validation du domaine côté client).
- **Inerte par défaut** : sans `DRACO_SESSION_SECRET`, aucun cookie
n'est émis et le comportement d'ObsiGate est inchangé.
## Configuration (déploiement)
| Variable | Rôle |
|---|---|
| `DRACO_SESSION_SECRET` | Secret partagé de signature — **le même** devra être configuré dans Flowdeck et TikTak pour leurs validateurs ACC-3.2. À générer fort (ex. 64 hex) et à ne jamais committer. |
| `DRACO_SESSION_SECRET_PREV` | Ancien secret pendant une rotation (24 h). |
| `DRACO_SESSION_COOKIE_DOMAIN` | Domaine du cookie (défaut `.dracodev.net` ; vide = hôte seul, dev local). |
| `DRACO_SSO_MACHINE_KEY` | Clé machine de lecture des révocations, à partager avec les apps consommatrices. |
⚠️ **Déploiement** : la fonction ne s'active qu'au redéploiement
d'ObsiGate ≥ 2.71.0 **avec** `DRACO_SESSION_SECRET` posé dans
l'environnement du conteneur. Le SSO complet exige ensuite les
validateurs ACC-3.2 (ObsiGate #9, Flowdeck #21, TikTak #213).
## Tests
`tests/test_draco_session.py` — 22 tests : claims minimaux, durée de
vie, falsification, expiration, rotation, révocation, fenêtre de
renouvellement, liste blanche `return_to` (8 cas), cookie posé à la
connexion (attributs), inactivité sans secret, révocation au logout,
renouvellement glissant via middleware, endpoint de révocations
(404/401/200).
@@ -0,0 +1,51 @@
# #217 — Validation locale du jeton draco_session (Fusion ACC-3.2)
Statut : ✅ livré le 2026-10-21 (2.72.0)
## Contexte
Suite de l'émettrice SSO (#216, ACC-3.1). Ici, ObsiGate joue son rôle
de **consommatrice** : `get_current_user` accepte le cookie partagé
`draco_session` à défaut de jeton natif valide. C'est la brique qui
servira aussi à l'API de l'agent commun (ACC-4) pour authentifier les
requêtes SSO. Les validateurs des autres applications sont leurs
tickets respectifs (Flowdeck #21, TikTak #213 — TikTak 0.52.0).
## Correspondance sub → compte local
ObsiGate est émettrice ET consommatrice : le `sub` du jeton **est**
son identifiant d'utilisateur — la résolution est directe, par `id`,
dans `draco_session.user_for_draco()` :
- signature/expiration/révocation vérifiées par `verify_token` (#216) ;
- compte **actif** exigé ;
- garde-fou BUG-028 appliqué à l'identique : un jeton draco émis
avant le dernier changement de mot de passe (`password_changed_at`)
est refusé ;
- le dict utilisateur retourné porte `_token_vaults` (droits du
compte), `_token_scope = "full"` et `_draco_sub` (traçabilité).
## Branchement
Dans `backend/auth/middleware.py`, chaque sortie « non authentifié »
du chemin natif (aucun jeton, jeton illisible, jti natif révoqué,
compte introuvable/inactif, jeton antérieur au changement de mot de
passe) passe par `_draco_fallback()`. Sûreté du repli après
déconnexion globale : le logout révoque aussi le `jti` draco et
efface le cookie (#216), donc un ancien cookie rejoué est refusé par
`verify_token`.
## Limite connue
Le repli authentifie les **appels d'API**. Le bootstrap d'une
session complète du SPA (jetons natifs émis à partir du seul cookie
draco) n'est pas couvert : pour ObsiGate, émettrice, la connexion
native EST la connexion SSO — le cas d'usage du repli est la
continuité d'API quand le jeton natif a expiré avant le cookie
partagé, et les consommateurs machine de l'API.
## Tests
`tests/test_draco_session.py` (+4) : `/api/auth/me` avec le seul
cookie draco (200, bon utilisateur), jti révoqué refusé, sub inconnu
refusé, inactivité sans secret configuré.
@@ -0,0 +1,73 @@
# #215 — Fusion bloc 1 : barre d'apps commune et références croisées des notes
> Programme **Fusion** (accès centralisé entre ObsiGate, Flowdeck, TikTak et
> Kanbante). Dépôt programme : `Projets/fusion-trois-apps` (épopée ACC-1,
> issues ObsiGate #5, #6, #7). Contrats : `Projets/dracodev-commons` v0.1.0
> (`contrats/liens-profonds.md`, `contrats/external_ref.schema.json`,
> snippet `ui/barre-apps/`).
## Ce qui est livré
### ACC-1.1 — Barre d'apps commune (#5)
Le snippet figé de commons est **vendangé** (copié, jamais chargé depuis une
autre app — la barre survit à l'arrêt des autres applications) :
- `frontend/draco-barre.css` / `frontend/draco-barre.js` : copie v0.1.0 ;
- markup + configuration dans `frontend/index.html`, au-dessus de `#app`
(`body.has-draco-bar` ajuste la hauteur de `.app-container` dans
`style.css`).
Phase 1 : navigation entre les quatre applications (ObsiGate marquée
active), sélecteur de domaine mémorisé en `localStorage` (`draco_domain`,
même clé que TikTak), recherche = focus de la recherche native, rail =
bouton latéral natif, menu = menu d'en-tête natif. Alertes unifiées et
panneau Spark affichent un message d'arrivée (fédération et agent commun :
phases ultérieures du programme) plutôt que des boutons morts. Les compteurs
croisés viendront avec la fédération ; seul le compteur local de chaque app
est alimenté en phase 1.
### ACC-1.3 — Références croisées `external_ref` (#7)
- **Modèle** : le schéma figé de commons. La note est propriétaire de ses
références (`owner_type = obsigate.note`, `owner_id = "<vault>:<chemin>"`).
- **Stockage** : JSON par utilisateur dans `data/external_refs/`
(`backend/external_refs.py`, même patron que `backend/history.py` —
pas de base de données). Surchargable par `OBSIGATE_EXTERNAL_REFS_DIR`.
- **API** (auth + `check_vault_access`) :
- `GET /api/refs?vault=&path=` — références d'une note ;
- `POST /api/refs` `{vault, path, url, label?}` — création par collage
d'URL profonde ; le parseur reconnaît les formes du contrat (TikTak
`?task=`/`?list=`, ObsiGate `#file=`, Flowdeck `/pages/`/`/collections/`,
issue Gitea = carte Kanbante) et répond **422** explicite sinon ;
- `DELETE /api/refs/{id}`.
- **UI** : section « Liens » en fin de note — liste avec pastille de couleur
de l'app cible, formulaire d'ajout, retrait en un clic. Rendue par le
module autonome `frontend/draco-liens.js`, branché sur l'événement
`obsigate:file-rendered` que `renderFile` émet (point d'extension
documenté dans `frontend/app.js`).
### ACC-1.2 — Notes projetées TikTak (#6)
La synchro TikTak ↔ ObsiGate écrit `tiktak_id: <id>` dans le frontmatter des
fichiers projetés. Le module `draco-liens.js` le détecte (lecture du brut via
`/api/file/{vault}/raw`) et affiche un bandeau « Tâche TikTak projetée » avec
le bouton **« Ouvrir dans TikTak »** (`https://tiktak.dracodev.net/?task=<id>`),
au lieu de traiter la note comme une note ordinaire.
## Limites assumées (v1)
- Le libellé d'une référence est un instantané (titre par défaut dérivé de
l'URL ou libellé libre) ; le rafraîchissement automatique du titre viendra
avec la fédération MCP (phase 3 du programme).
- Un lien dont la cible a disparu n'est pas détecté automatiquement
(`status` reste `ok`) — la marque « cible introuvable » viendra avec la
couche données.
- La section « Liens » n'apparaît que dans la vue lecture d'une note
(pas dans l'éditeur).
## Tests
`tests/test_external_refs.py` : parseur (6 formes reconnues, 7 refusées),
stockage (cycle + isolation par utilisateur), API (cycle complet, 422, 404,
403 vault inconnu).
+7
View File
@@ -3424,6 +3424,13 @@
// Initialize outline/TOC for this document // Initialize outline/TOC for this document
OutlineManager.init(); OutlineManager.init();
// Fusion #215 (ACC-1.2/1.3) — point d'extension : le module
// draco-liens.js rend la section « Liens » de la note
// (références croisées + « ouvrir dans TikTak » si projetée).
document.dispatchEvent(new CustomEvent("obsigate:file-rendered", {
detail: { vault: data.vault, path: data.path },
}));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+127
View File
@@ -0,0 +1,127 @@
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2)
Jetons repris du prototype validé Console v7.2.
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */
.draco-bar {
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
--agent: #c2437e; --danger: #cf4444;
height: 60px; box-sizing: border-box;
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
align-items: center; gap: 16px; padding: 0 12px;
background: var(--surface); border-bottom: 1px solid var(--line);
font: 500 14px/1.3 system-ui, "Segoe UI", sans-serif; color: var(--ink);
}
@media (prefers-color-scheme: dark) {
.draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
}
[data-theme="dark"] .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
.draco-bar-zone { display: flex; align-items: center; gap: 8px; min-width: 0; }
.draco-bar-centre { justify-content: center; }
.draco-bar-droite { justify-content: flex-end; }
/* Marque */
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; }
.draco-brand-mark { color: var(--agent); }
/* Onglets d'apps — accordéon : seule l'app courante est déployée */
.draco-apptabs { display: flex; gap: 6px; }
.draco-apptab {
position: relative; display: flex; align-items: center; gap: 8px;
height: 44px; padding: 0 9px; border-radius: 12px;
color: var(--ink); text-decoration: none; background: transparent;
transition: background .18s ease;
}
.draco-apptab:hover { background: var(--surface-2); }
.draco-apptab-ico {
width: 30px; height: 30px; flex: none; display: grid; place-items: center;
border-radius: 9px; background: var(--app); color: #fff; font-size: 15px;
}
.draco-apptab-txt { display: none; flex-direction: column; line-height: 1.15; }
.draco-apptab-txt strong { font-size: 14px; font-weight: 700; }
.draco-apptab-txt small { font-size: 11.5px; color: var(--ink-2); }
.draco-apptab[data-active="true"] { background: var(--surface-3); box-shadow: inset 0 -2px 0 var(--app, var(--agent)); }
.draco-apptab[data-active="true"] .draco-apptab-txt { display: flex; }
/* Pastilles compteurs — petites, rouges, coin bas-droit (standard v7.1) */
.draco-badge {
position: absolute; right: -4px; bottom: -4px; min-width: 17px; height: 17px;
padding: 0 4px; border-radius: 9px; box-sizing: border-box;
background: #e5484d; color: #fff; font-size: 10.5px; font-weight: 700;
display: grid; place-items: center; border: 2px solid var(--surface);
}
.draco-badge[hidden] { display: none; }
/* Recherche */
.draco-search {
display: flex; align-items: center; gap: 8px; width: 100%; height: 38px;
padding: 0 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--surface-3); color: var(--ink-3); cursor: pointer; font: inherit;
}
.draco-search kbd {
margin-left: auto; font: 600 11px system-ui; color: var(--ink-2);
border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px;
}
/* Domaines — contrôle segmenté */
.draco-domaines { display: flex; align-items: center; gap: 2px; background: var(--surface-3); border-radius: 10px; padding: 3px; }
.draco-domaines-lbl { font-size: 10.5px; letter-spacing: .06em; color: var(--ink-3); padding: 0 6px; text-transform: uppercase; }
.draco-domaines button {
border: 0; background: transparent; color: var(--ink-2); font: inherit; font-size: 13px;
height: 30px; padding: 0 10px; border-radius: 8px; cursor: pointer;
}
.draco-domaines button[data-on="true"] { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: 0 1px 3px rgb(0 0 0 / .18); }
/* Alertes — la cloche ne disparaît JAMAIS (correctif v7.2) */
.draco-alertes {
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
padding: 0 11px; border-radius: 10px; border: 1px solid var(--line);
background: transparent; color: var(--ink); font: inherit; cursor: pointer;
}
/* Spark */
.draco-spark {
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
padding: 0 14px; border: 0; border-radius: 10px; cursor: pointer;
background: var(--agent); color: #fff; font: inherit; font-weight: 700;
}
.draco-spark .draco-badge { border-color: var(--agent); }
.draco-icon-btn {
width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent;
background: transparent; color: var(--ink-2); font: inherit; cursor: pointer;
}
.draco-icon-btn:hover { background: var(--surface-2); }
.draco-avatar {
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
background: #2f6fed; color: #fff; font-weight: 700;
}
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
.draco-bar-rappel {
position: fixed; top: 0; left: 16px; z-index: 60; cursor: pointer;
background: var(--surface, #1a1c1a); border: 1px solid var(--line, #2e302c); border-top: 0;
border-radius: 0 0 10px 10px; padding: 5px 12px; font: 600 12px system-ui; color: var(--ink-2, #a6aba2);
}
/* Paliers — les zones latérales cèdent, la barre ne casse jamais */
@media (max-width: 1440px) {
.draco-brand-name, .draco-domaines-lbl { display: none; }
.draco-bar { grid-template-columns: auto minmax(200px, 440px) auto; }
}
@media (max-width: 1200px) {
.draco-search-txt, .draco-search kbd, .draco-alertes-txt, .draco-spark-txt { display: none; }
.draco-search { width: 38px; padding: 0; justify-content: center; }
.draco-bar { grid-template-columns: auto auto 1fr; }
.draco-bar-droite { justify-content: flex-end; }
}
@media (max-width: 700px) {
/* Mobile : la barre du haut se condense ; la navigation d'apps passe
en barre basse propre à chaque app (patron du prototype v5+). */
.draco-apptabs, .draco-domaines-lbl { display: none; }
.draco-bar { height: 52px; }
}
+56
View File
@@ -0,0 +1,56 @@
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2)
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
* proscrits dans tout le programme).
*
* Configuration attendue avant le chargement :
* window.DRACO_BAR_CONFIG = {
* current: "tiktak", // app courante
* domain: "pro", // domaine actif
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
* onAction: function (act, el) { … } // l'app relaie les actions
* } // qu'elle ne connaît pas
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
*/
(function () {
"use strict";
var cfg = window.DRACO_BAR_CONFIG || {};
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
var b = document.querySelector('.draco-badge[data-badge="' + k + '"]');
if (!b) return;
b.textContent = counts[k];
b.hidden = !(counts[k] > 0);
});
}
function setCurrent(app) {
document.querySelectorAll(".draco-apptab").forEach(function (t) {
t.dataset.active = t.dataset.app === app ? "true" : "false";
});
}
function setDomain(dom) {
document.querySelectorAll('.draco-domaines [data-dom]').forEach(function (b) {
b.dataset.on = b.dataset.dom === dom ? "true" : "false";
});
}
document.addEventListener("click", function (ev) {
var el = ev.target.closest("[data-act]");
if (!el) return;
var act = el.dataset.act;
if (act === "domaine") setDomain(el.dataset.dom);
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
});
/* API publique : l'app met les compteurs à jour sans recharger. */
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
})();
+187
View File
@@ -0,0 +1,187 @@
/* Section « Liens » des notes — Fusion #215 (ACC-1.2 + ACC-1.3).
*
* Module autonome (vanilla, zéro dépendance, zéro build) : il écoute
* l'événement « obsigate:file-rendered » émis par renderFile
* (frontend/app.js) et rend sa section à la fin de #content-area :
*
* - ACC-1.2 : si la note est une projection TikTak (frontmatter
* « tiktak_id » écrit par la synchro TikTak ↔ ObsiGate), un bandeau
* « Tâche TikTak projetée » avec le bouton « Ouvrir dans TikTak »
* (lien profond https://tiktak.dracodev.net/?task=<id>) ;
* - ACC-1.3 : les références croisées de la note (GET /api/refs),
* ajout par collage d'une URL profonde, retrait en un clic.
*
* Textes via window.t (i18n FR/EN — clés « draco.links.* » des locales).
*/
(function () {
"use strict";
var COLORS = {
obsigate: "#7a5af5",
flowdeck: "#2f6fed",
tiktak: "#e8762c",
kanbante: "#0d9488",
};
function t(key, fallback) {
try {
var v = window.t && window.t(key);
return v && v !== key ? v : fallback;
} catch (e) {
return fallback;
}
}
function authHeaders() {
var tok = null;
try {
tok = sessionStorage.getItem("obsigate_access_token");
} catch (e) { /* stockage indisponible */ }
return tok ? { Authorization: "Bearer " + tok } : {};
}
async function api(path, opts) {
var options = opts || {};
options.headers = Object.assign(
{ "Content-Type": "application/json" },
authHeaders(),
options.headers || {}
);
options.credentials = "same-origin";
var resp = await fetch(path, options);
if (!resp.ok) {
var detail = "";
try {
detail = (await resp.json()).detail || "";
} catch (e) { /* corps non JSON */ }
throw new Error(detail || "HTTP " + resp.status);
}
return resp.json();
}
function el(tag, cls, text) {
var node = document.createElement(tag);
if (cls) node.className = cls;
if (text !== undefined) node.textContent = text;
return node;
}
async function render(vault, path) {
var area = document.getElementById("content-area");
if (!area || !vault || !path) return;
var old = document.getElementById("draco-liens");
if (old) old.remove();
var section = el("section", "draco-liens");
section.id = "draco-liens";
var title = el("h3", "draco-liens-title", t("draco.links.title", "Liens"));
section.appendChild(title);
area.appendChild(section);
// ACC-1.2 — note projetée depuis TikTak ?
if (/\.md$/i.test(path)) {
try {
var rawUrl = "/api/file/" + encodeURIComponent(vault) + "/raw?path=" +
encodeURIComponent(path);
var rawData = await api(rawUrl);
var m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(rawData.raw || "");
var tid = m && /^tiktak_id:\s*(\d+)\s*$/m.exec(m[1]);
if (tid) {
var bandeau = el("div", "draco-liens-projete");
bandeau.appendChild(el("span", "draco-liens-badge",
t("draco.links.projected", "Tâche TikTak projetée")));
var ouvrir = el("a", "btn-action draco-liens-ouvrir",
t("draco.links.openInTikTak", "Ouvrir dans TikTak"));
ouvrir.href = "https://tiktak.dracodev.net/?task=" + tid[1];
ouvrir.target = "_blank";
ouvrir.rel = "noopener";
bandeau.appendChild(ouvrir);
section.appendChild(bandeau);
}
} catch (e) { /* la détection est opportuniste : silence */ }
}
// ACC-1.3 — références croisées de la note
var list = el("ul", "draco-liens-list");
section.appendChild(list);
var erreur = el("p", "draco-liens-erreur");
erreur.hidden = true;
section.appendChild(erreur);
async function refresh() {
list.innerHTML = "";
try {
var data = await api("/api/refs?vault=" + encodeURIComponent(vault) +
"&path=" + encodeURIComponent(path));
(data.refs || []).forEach(function (r) {
var li = el("li", "draco-liens-item");
var dot = el("span", "draco-ref-dot");
dot.style.background = COLORS[r.target_app] || "#888";
li.appendChild(dot);
var a = el("a", "", r.label);
a.href = r.target_url;
a.target = "_blank";
a.rel = "noopener";
li.appendChild(a);
var del = el("button", "draco-liens-del", "✕");
del.type = "button";
del.title = t("draco.links.remove", "Retirer le lien");
del.addEventListener("click", async function () {
try {
await api("/api/refs/" + encodeURIComponent(r.id), { method: "DELETE" });
refresh();
} catch (e) {
erreur.textContent = e.message;
erreur.hidden = false;
}
});
li.appendChild(del);
list.appendChild(li);
});
} catch (e) { /* section facultative : ne jamais casser la note */ }
}
var form = el("form", "draco-liens-form");
var urlInput = el("input");
urlInput.type = "url";
urlInput.required = true;
urlInput.placeholder = t("draco.links.urlPlaceholder",
"Coller une URL profonde (Flowdeck, TikTak, Gitea, ObsiGate)…");
var labelInput = el("input");
labelInput.type = "text";
labelInput.placeholder = t("draco.links.labelPlaceholder", "Libellé (optionnel)");
var submit = el("button", "btn-action", t("draco.links.add", "Lier"));
submit.type = "submit";
form.appendChild(urlInput);
form.appendChild(labelInput);
form.appendChild(submit);
form.addEventListener("submit", async function (ev) {
ev.preventDefault();
erreur.hidden = true;
try {
await api("/api/refs", {
method: "POST",
body: JSON.stringify({
vault: vault,
path: path,
url: urlInput.value.trim(),
label: labelInput.value.trim() || null,
}),
});
urlInput.value = "";
labelInput.value = "";
refresh();
} catch (e) {
erreur.textContent = e.message;
erreur.hidden = false;
}
});
section.appendChild(form);
refresh();
}
document.addEventListener("obsigate:file-rendered", function (ev) {
if (ev.detail) render(ev.detail.vault, ev.detail.path);
});
})();
+6
View File
@@ -500,6 +500,10 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
ta.value = val().substring(0, p.s) + text + val().substring(p.e); ta.value = val().substring(0, p.s) + text + val().substring(p.e);
var np = cursorOffset != null ? p.s + cursorOffset : p.s + text.length; var np = cursorOffset != null ? p.s + cursorOffset : p.s + text.length;
setPos(np); setPos(np);
// Mutation programmatique : pas d'evenement 'input' → sans autoHeight/updateLineNumbers,
// sinon la textarea reste d'une ligne quand on insère un tableau/code.
autoHeight();
updateLineNumbers();
markDirty(); markDirty();
} }
@@ -509,6 +513,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
var sel = val().substring(p.s, p.e); var sel = val().substring(p.s, p.e);
ta.value = val().substring(0, p.s) + before + sel + (after || before) + val().substring(p.e); ta.value = val().substring(0, p.s) + before + sel + (after || before) + val().substring(p.e);
setPos(p.s + before.length + sel.length + (after || before).length); setPos(p.s + before.length + sel.length + (after || before).length);
autoHeight();
updateLineNumbers();
markDirty(); markDirty();
} }
+112 -1
View File
@@ -75,6 +75,7 @@
@media (prefers-reduced-motion: reduce) { .bs-spinner { animation-duration: 1.8s; } } @media (prefers-reduced-motion: reduce) { .bs-spinner { animation-duration: 1.8s; } }
</style> </style>
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/static/draco-barre.css" />
<link <link
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
@@ -222,7 +223,7 @@
}; };
</script> </script>
</head> </head>
<body> <body class="has-draco-bar">
<!-- Boot splash : visible tant que l'app n'a pas booté avec son CSS. <!-- Boot splash : visible tant que l'app n'a pas booté avec son CSS.
Sur desktop, la page embarquée (tauri://) n'a pas /static/style.css → Sur desktop, la page embarquée (tauri://) n'a pas /static/style.css →
sans ce splash, le squelette HTML brut serait visible pendant le sans ce splash, le squelette HTML brut serait visible pendant le
@@ -329,6 +330,114 @@
</div> </div>
<!-- Main Application (wrapped for show/hide) --> <!-- Main Application (wrapped for show/hide) -->
<!-- Barre d'apps commune dracodev — Fusion #215 (ACC-1.1).
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.0
(ui/barre-apps/) : ne pas modifier la structure ici ; toute
évolution passe par une nouvelle version de commons.
CSS/JS : /static/draco-barre.css, /static/draco-barre.js. -->
<header class="draco-bar" data-current="obsigate">
<div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
<a class="draco-brand" href="/" aria-label="dracodev">
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
</a>
<nav class="draco-apptabs" aria-label="Applications">
<a class="draco-apptab" data-app="obsigate" href="https://og.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
</a>
<a class="draco-apptab" data-app="flowdeck" href="https://flowdeck.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
</a>
<a class="draco-apptab" data-app="tiktak" href="https://tiktak.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
</a>
<a class="draco-apptab" data-app="kanbante" href="https://kanbante.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
</a>
</nav>
</div>
<div class="draco-bar-zone draco-bar-centre">
<button type="button" class="draco-search" data-act="palette" aria-label="Rechercher partout (Ctrl+K)">
<span class="draco-search-ico">⌕</span>
<span class="draco-search-txt">Rechercher partout…</span>
<kbd>⌘K</kbd>
</button>
</div>
<div class="draco-bar-zone draco-bar-droite">
<div class="draco-domaines" role="group" aria-label="Domaine">
<span class="draco-domaines-lbl">Domaine</span>
<button type="button" data-act="domaine" data-dom="pro">Pro</button>
<button type="button" data-act="domaine" data-dom="famille">Famille</button>
<button type="button" data-act="domaine" data-dom="perso">Perso</button>
</div>
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<span class="draco-alertes-txt">Alertes</span>
<span class="draco-badge" data-badge="alertes" hidden>0</span>
</button>
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
<span class="draco-badge" data-badge="spark" hidden>0</span>
</button>
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
<span class="draco-avatar" id="draco-avatar" title="">✦</span>
</div>
</header>
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
<script>
/* Configuration ObsiGate de la barre commune (Fusion #215).
Phase 1 : navigation entre apps et domaine mémorisé
localement ; la recherche focus la recherche native
d'ObsiGate ; Alertes unifiées et Spark arrivent avec la
fédération / l'agent commun (phases suivantes). */
window.DRACO_BAR_CONFIG = {
current: "obsigate",
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
counts: {},
onAction: function (act, el) {
var dire = function (m) {
var d = document.createElement("div");
d.textContent = m;
d.style.cssText = "position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:20000;background:#161b22;color:#e6edf3;padding:10px 16px;border-radius:10px;font:500 13.5px system-ui;box-shadow:0 6px 24px rgb(0 0 0/.35)";
document.body.appendChild(d);
setTimeout(function () { d.remove(); }, 3400);
};
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
if (act === "rail") { var b = document.getElementById("sidebar-toggle-btn"); if (b) b.click(); return; }
if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; }
if (act === "menu") { var m = document.getElementById("header-menu-btn"); if (m) m.click(); return; }
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
if (act === "rappel-entete") {
var bar = document.querySelector(".draco-bar");
if (bar) bar.style.display = "";
el.hidden = true;
}
}
};
/* Avatar : initiale de l'utilisateur courant (best effort). */
window.addEventListener("DOMContentLoaded", function () {
var tok = null;
try { tok = sessionStorage.getItem("obsigate_access_token"); } catch (e) { }
fetch("/api/auth/me", {
credentials: "same-origin",
headers: tok ? { Authorization: "Bearer " + tok } : {}
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (u) {
var nom = u && (u.display_name || u.username);
if (nom) {
var av = document.getElementById("draco-avatar");
if (av) { av.textContent = nom.charAt(0).toUpperCase(); av.title = nom; }
}
}).catch(function () { /* avatar générique conservé */ });
});
</script>
<div id="app" class="app-container"> <div id="app" class="app-container">
<!-- Header --> <!-- Header -->
<header class="header"> <header class="header">
@@ -5931,5 +6040,7 @@ curl -X POST https://votre-serveur.com/webhook \
</div> </div>
</div> </div>
</div> </div>
<script defer src="/static/draco-barre.js"></script>
<script defer src="/static/draco-liens.js"></script>
</body> </body>
</html> </html>
+19
View File
@@ -121,6 +121,25 @@ const AuthManager = {
if (tokenData.user) { if (tokenData.user) {
sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user)); sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user));
} }
this._honorReturnTo();
},
// Fusion ACC-3.1 (#216) : après connexion chez l'émettrice SSO,
// repartir vers l'application demanderesse. Le return_to est
// re-validé ici (https + domaine dracodev.net, jamais l'hôte
// courant) en plus de la validation serveur de /sso/authorize.
_honorReturnTo() {
try {
const rt = new URLSearchParams(window.location.search).get("return_to");
if (!rt) return;
const u = new URL(rt);
const okHost = u.hostname === "dracodev.net"
|| u.hostname.endsWith(".dracodev.net");
if (u.protocol === "https:" && okHost
&& u.hostname !== window.location.hostname) {
window.location.replace(rt);
}
} catch (e) { /* return_to illisible : on reste sur place */ }
}, },
getToken() { getToken() {
+7 -2
View File
@@ -239,9 +239,14 @@ async function onDrop(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const isExternalFiles = e.dataTransfer && ( // BUG-117: 'items.length > 0' comptait aussi les formats STRING (tree:vault/path/
// type) ajoutés par setData au dragstart interne → tout déplacement interne était
// classé à tort « upload externe » et avalé silencieusement. Seuls les items de
// kind 'file' (ou le type 'Files', ou files non vide) désignent un drop externe,
// et un dragState actif (drag interne) prime toujours.
const isExternalFiles = !dragState && e.dataTransfer && (
(e.dataTransfer.files && e.dataTransfer.files.length > 0) || (e.dataTransfer.files && e.dataTransfer.files.length > 0) ||
(e.dataTransfer.items && e.dataTransfer.items.length > 0) || Array.from(e.dataTransfer.items || []).some((it) => it.kind === 'file') ||
Array.from(e.dataTransfer.types || []).includes('Files') Array.from(e.dataTransfer.types || []).includes('Files')
); );
+74
View File
@@ -1826,9 +1826,13 @@ export const ContextMenuManager = {
this._addSeparator(); this._addSeparator();
this._addItem('brain', '🧠 BooksLM', () => { import('./bookslm.js').then(m => m.default.open(this._targetVault, this._targetPath)); }, false); this._addItem('brain', '🧠 BooksLM', () => { import('./bookslm.js').then(m => m.default.open(this._targetVault, this._targetPath)); }, false);
this._addSeparator(); this._addSeparator();
this._addItem('move', t('common.move'), () => this._moveItem(), isReadonly);
this._addItem('copy', t('common.duplicate'), () => this._duplicateItem(), isReadonly);
this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly); this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly);
} else if (type === 'file') { } else if (type === 'file') {
this._addItem('move', t('common.move'), () => this._moveItem(), isReadonly);
this._addItem('copy', t('common.duplicate'), () => this._duplicateItem(), isReadonly);
this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly); this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly);
this._addSeparator(); this._addSeparator();
@@ -1918,6 +1922,76 @@ export const ContextMenuManager = {
FileOperations.confirmDeleteFile(this._targetVault, this._targetPath); FileOperations.confirmDeleteFile(this._targetVault, this._targetPath);
}, },
// #BUG-117 — Déplacer / Dupliquer via le modal harmonisé promptTargetFolder
// (réutilisé de upload.js, opts pour titre/bouton). Le backend ne supporte
// que le même vault ; choix d'un autre vault = toast d'erreur.
async _pickDestination(title) {
const vault = this._targetVault;
const name = this._targetPath.split('/').pop();
const parent = this._targetPath.includes('/') ? this._targetPath.slice(0, this._targetPath.lastIndexOf('/')) : '';
const { promptTargetFolder } = await import('./upload.js');
// Destination pré-sélectionnée = emplacement actuel (sinon '' = racine,
// ce qui dupliquait/mouvait à la racine par défaut).
const picked = await promptTargetFolder(vault, parent, [], {
title: title + ' — ' + name,
summaryText: t('ui.move_summary', { name: name }),
confirmLabel: title,
});
if (!picked) return null;
if (picked.vault !== vault) {
showToast(t('toast.move_cross_vault'), 'error');
return null;
}
return picked;
},
async _moveItem() {
const vault = this._targetVault;
const path = this._targetPath;
const type = this._targetType;
let picked = null;
try {
picked = await this._pickDestination(t('common.move'));
if (!picked) return;
const result = await api(`/api/move/${encodeURIComponent(vault)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_path: path, destination_dir: picked.targetDir }),
});
await refreshSidebarTreePreservingState();
const nextPath = result.new_path;
if (state.currentVault === vault && state.currentPath && (state.currentPath === path || (type === 'directory' && state.currentPath.startsWith(path + '/')))) {
if (type === 'file') {
await openFile(vault, nextPath);
} else {
state.currentPath = nextPath + state.currentPath.slice(path.length);
syncActiveFileTreeItem();
}
}
showToast(picked.targetDir ? t('toast.moved', { dir: picked.targetDir }) : t('toast.move_root'), 'success');
} catch (err) {
showToast(err.message || t('common.error'), 'error');
}
},
async _duplicateItem() {
const vault = this._targetVault;
const path = this._targetPath;
try {
const picked = await this._pickDestination(t('common.duplicate'));
if (!picked) return;
const result = await api(`/api/duplicate/${encodeURIComponent(vault)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_path: path, destination_dir: picked.targetDir }),
});
await refreshSidebarTreePreservingState();
showToast(t('toast.duplicated', { name: result.new_path.split('/').pop() }), 'success');
} catch (err) {
showToast(err.message || t('common.error'), 'error');
}
},
_copyPath(path) { _copyPath(path) {
// Try modern clipboard API first, fall back to execCommand for non-secure contexts // Try modern clipboard API first, fall back to execCommand for non-secure contexts
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
+4 -4
View File
@@ -232,7 +232,7 @@ export async function handleVaultDropUpload(vault, targetDir, items) {
* @param {Array} items - Items to upload * @param {Array} items - Items to upload
* @returns {Promise<{ vault: string, targetDir: string }|null>} Selected target or null if cancelled. * @returns {Promise<{ vault: string, targetDir: string }|null>} Selected target or null if cancelled.
*/ */
export async function promptTargetFolder(initialVault, initialDir = '', items = []) { export async function promptTargetFolder(initialVault, initialDir = '', items = [], opts = {}) {
let vaultsList = []; let vaultsList = [];
try { try {
const vRes = await api('/api/vaults'); const vRes = await api('/api/vaults');
@@ -316,12 +316,12 @@ export async function promptTargetFolder(initialVault, initialDir = '', items =
overlay.innerHTML = ` overlay.innerHTML = `
<div class="obsigate-modal" style="min-width: 460px; max-width: 560px;"> <div class="obsigate-modal" style="min-width: 460px; max-width: 560px;">
<div class="obsigate-modal-header"> <div class="obsigate-modal-header">
<span class="obsigate-modal-title">📁 ${escapeHtml(t('upload.choose_target_folder') || "Destination de l'importation")}</span> <span class="obsigate-modal-title">📁 ${escapeHtml(opts.title || t('upload.choose_target_folder') || "Destination de l'importation")}</span>
<button class="modal-close modal-btn" type="button" aria-label="Fermer" style="padding: 4px 8px; line-height: 1;">×</button> <button class="modal-close modal-btn" type="button" aria-label="Fermer" style="padding: 4px 8px; line-height: 1;">×</button>
</div> </div>
<div class="obsigate-modal-body"> <div class="obsigate-modal-body">
<p style="margin: 0 0 16px 0; color: var(--text-muted); font-size: 0.9rem;"> <p style="margin: 0 0 16px 0; color: var(--text-muted); font-size: 0.9rem;">
${escapeHtml(t('upload.import_summary', { files: fileCount, dirs: dirCount }) || `Éléments à importer : ${fileCount} fichier(s)${dirCount ? `, ${dirCount} dossier(s)` : ''}`)} ${opts.summaryText ? escapeHtml(opts.summaryText) : escapeHtml(t('upload.import_summary', { files: fileCount, dirs: dirCount }) || `Éléments à importer : ${fileCount} fichier(s)${dirCount ? `, ${dirCount} dossier(s)` : ''}`)}
</p> </p>
<div class="modal-form-group"> <div class="modal-form-group">
@@ -352,7 +352,7 @@ export async function promptTargetFolder(initialVault, initialDir = '', items =
</div> </div>
<div class="obsigate-modal-footer"> <div class="obsigate-modal-footer">
<button type="button" class="modal-btn modal-cancel-btn">${escapeHtml(t('button.cancel') || 'Annuler')}</button> <button type="button" class="modal-btn modal-cancel-btn">${escapeHtml(t('button.cancel') || 'Annuler')}</button>
<button type="button" class="modal-btn primary modal-confirm-btn">${escapeHtml(t('upload.import_button') || 'Importer')}</button> <button type="button" class="modal-btn primary modal-confirm-btn">${escapeHtml(opts.confirmLabel || t('upload.import_button') || 'Importer')}</button>
</div> </div>
</div> </div>
`; `;
+13
View File
@@ -358,6 +358,7 @@
"common.error": "Error", "common.error": "Error",
"common.loading": "Loading...", "common.loading": "Loading...",
"common.move": "Move", "common.move": "Move",
"common.duplicate": "Duplicate",
"common.never": "Never", "common.never": "Never",
"common.next": "Next", "common.next": "Next",
"common.no": "No", "common.no": "No",
@@ -751,6 +752,13 @@
"upload.custom_folder": "Or enter a new subfolder path...", "upload.custom_folder": "Or enter a new subfolder path...",
"upload.import_summary": "Items to import: {files} file(s), {dirs} folder(s)", "upload.import_summary": "Items to import: {files} file(s), {dirs} folder(s)",
"upload.import_button": "Import", "upload.import_button": "Import",
"draco.links.add": "Link",
"draco.links.labelPlaceholder": "Label (optional)",
"draco.links.openInTikTak": "Open in TikTak",
"draco.links.projected": "Projected TikTak task",
"draco.links.remove": "Remove link",
"draco.links.title": "Links",
"draco.links.urlPlaceholder": "Paste a deep link (Flowdeck, TikTak, Gitea, ObsiGate)…",
"editor.cancel": "Cancel", "editor.cancel": "Cancel",
"editor.close": "Close", "editor.close": "Close",
"editor.delete_error": "Delete error", "editor.delete_error": "Delete error",
@@ -1834,6 +1842,11 @@
"toast.no_open_file": "No file open", "toast.no_open_file": "No file open",
"toast.not_found_tree": "Item not found in tree", "toast.not_found_tree": "Item not found in tree",
"toast.path_copied": "Path copied: {path}", "toast.path_copied": "Path copied: {path}",
"ui.move_summary": "Item to move: {name}",
"toast.moved": "Moved to: {dir}",
"toast.move_root": "Moved to vault root",
"toast.duplicated": "Duplicated: {name}",
"toast.move_cross_vault": "Cross-vault move/duplicate not supported",
"toast.reindex_done": "Reindexing complete", "toast.reindex_done": "Reindexing complete",
"toast.reindex_started": "Reindexing...", "toast.reindex_started": "Reindexing...",
"toast.rename_error": "Rename error", "toast.rename_error": "Rename error",
+13
View File
@@ -358,6 +358,7 @@
"common.error": "Erreur", "common.error": "Erreur",
"common.loading": "Chargement...", "common.loading": "Chargement...",
"common.move": "Déplacer", "common.move": "Déplacer",
"common.duplicate": "Dupliquer",
"common.never": "Jamais", "common.never": "Jamais",
"common.next": "Suivant", "common.next": "Suivant",
"common.no": "Non", "common.no": "Non",
@@ -751,6 +752,13 @@
"upload.custom_folder": "Ou saisir un nouveau sous-dossier...", "upload.custom_folder": "Ou saisir un nouveau sous-dossier...",
"upload.import_summary": "Éléments à importer : {files} fichier(s), {dirs} dossier(s)", "upload.import_summary": "Éléments à importer : {files} fichier(s), {dirs} dossier(s)",
"upload.import_button": "Importer", "upload.import_button": "Importer",
"draco.links.add": "Lier",
"draco.links.labelPlaceholder": "Libellé (optionnel)",
"draco.links.openInTikTak": "Ouvrir dans TikTak",
"draco.links.projected": "Tâche TikTak projetée",
"draco.links.remove": "Retirer le lien",
"draco.links.title": "Liens",
"draco.links.urlPlaceholder": "Coller une URL profonde (Flowdeck, TikTak, Gitea, ObsiGate)…",
"editor.cancel": "Annuler", "editor.cancel": "Annuler",
"editor.close": "Fermer", "editor.close": "Fermer",
"editor.delete_error": "Erreur de suppression", "editor.delete_error": "Erreur de suppression",
@@ -1834,6 +1842,11 @@
"toast.no_open_file": "Aucun fichier ouvert", "toast.no_open_file": "Aucun fichier ouvert",
"toast.not_found_tree": "Élément introuvable dans l'arborescence", "toast.not_found_tree": "Élément introuvable dans l'arborescence",
"toast.path_copied": "Chemin copié : {path}", "toast.path_copied": "Chemin copié : {path}",
"ui.move_summary": "Élément à déplacer : {name}",
"toast.moved": "Déplacé vers : {dir}",
"toast.move_root": "Déplacé vers la racine de la voute",
"toast.duplicated": "Dupliqué : {name}",
"toast.move_cross_vault": "Déplacement/duplication entre voutes non supporté",
"toast.reindex_done": "Réindexation terminée", "toast.reindex_done": "Réindexation terminée",
"toast.reindex_started": "Réindexation en cours...", "toast.reindex_started": "Réindexation en cours...",
"toast.rename_error": "Erreur lors du renommage", "toast.rename_error": "Erreur lors du renommage",
+39
View File
@@ -14292,3 +14292,42 @@ textarea.file-chat-input {
/* — Barre d'apps commune dracodev + section « Liens » des notes
(Fusion #215, snippet figé dracodev-commons v0.1.0) — */
body.has-draco-bar .app-container { height: calc(100vh - 60px); }
@media (max-width: 700px) {
body.has-draco-bar .app-container { height: calc(100vh - 52px); }
}
.draco-liens {
margin: 28px 0 8px; padding: 16px 18px;
border: 1px solid var(--border); border-radius: 12px;
background: var(--bg-secondary);
}
.draco-liens-title { margin: 0 0 10px; font-size: 15px; color: var(--text-primary); }
.draco-liens-projete {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
background: var(--bg-hover);
}
.draco-liens-badge { font-weight: 700; color: #e8762c; font-size: 13px; }
.draco-liens-ouvrir { text-decoration: none; }
.draco-liens-list { list-style: none; margin: 0 0 12px; padding: 0; }
.draco-liens-item { display: flex; align-items: center; gap: 4px; padding: 4px 0; }
.draco-ref-dot {
display: inline-block; width: 9px; height: 9px; border-radius: 50%;
margin-right: 7px; flex: none;
}
.draco-liens-item a { color: var(--text-primary); }
.draco-liens-del {
margin-left: 8px; border: 0; background: transparent; cursor: pointer;
color: var(--text-secondary); font-size: 12px;
}
.draco-liens-del:hover { color: var(--err); }
.draco-liens-form { display: flex; gap: 6px; flex-wrap: wrap; }
.draco-liens-form input {
flex: 1 1 200px; min-width: 0; padding: 7px 10px; border-radius: 8px;
border: 1px solid var(--border); background: var(--bg-primary);
color: var(--text-primary); font: inherit; font-size: 13.5px;
}
.draco-liens-erreur { color: var(--err); font-size: 13px; margin: 8px 0 0; }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsigate", "name": "obsigate",
"version": "2.68.5", "version": "2.72.0",
"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": {
+35 -1
View File
@@ -1002,4 +1002,38 @@ class TestStaticCaching:
def test_static_asset_revalidates(self, client): def test_static_asset_revalidates(self, client):
resp = client.get("/static/style.css") resp = client.get("/static/style.css")
assert resp.status_code == 200 assert resp.status_code == 200
assert "no-cache" in resp.headers.get("cache-control", "") assert "no-cache" in resp.headers.get("cache-control", "")
class TestDuplicateApi:
"""BUG-117 : endpoint POST /api/duplicate — copie fichiers/répertoires."""
def test_duplicate_file_and_collision(self, client):
r = client.post("/api/duplicate/TestVault", json={"source_path": "note1.md", "destination_dir": ""})
assert r.status_code == 200
assert r.json()["new_path"] == "note1 copie.md"
# collision -> copie 2
r2 = client.post("/api/duplicate/TestVault", json={"source_path": "note1.md", "destination_dir": ""})
assert r2.json()["new_path"] == "note1 copie 2.md"
orig = client.get("/api/file/TestVault", params={"path": "note1.md"}).json()["html"]
copy = client.get("/api/file/TestVault", params={"path": "note1 copie.md"}).json()["html"]
assert copy == orig
def test_duplicate_directory_into_target_and_index(self, client):
assert client.post("/api/directory/TestVault", json={"path": "notes/sub"}).status_code == 200
assert client.post("/api/file/TestVault", json={"path": "notes/a.md", "content": "secret note A"}).status_code == 200
r = client.post("/api/duplicate/TestVault", json={"source_path": "notes", "destination_dir": "Projets"})
assert r.status_code == 200
new = r.json()["new_path"]
assert new == "Projets/notes copie"
paths = [p["path"] for p in client.get("/api/vault/TestVault/paths").json()["results"]]
assert any(p.startswith(new + "/") for p in paths), "copy not indexed"
def test_duplicate_directory_inside_itself_rejected(self, client):
assert client.post("/api/directory/TestVault", json={"path": "notes"}).status_code == 200
r = client.post("/api/duplicate/TestVault", json={"source_path": "notes", "destination_dir": "notes"})
assert r.status_code == 400
def test_duplicate_not_found(self, client):
r = client.post("/api/duplicate/TestVault", json={"source_path": "nonexistent.md", "destination_dir": ""})
assert r.status_code == 404
+234
View File
@@ -0,0 +1,234 @@
# tests/test_draco_session.py
# Fusion ACC-3.1 (#216) — émettrice SSO draco_session : émission à la
# connexion, vérification (signature, expiration, révocation, rotation),
# renouvellement glissant, return_to en liste blanche, révocations.
import time
import pytest
from jose import jwt as jose_jwt
from backend import draco_session
USER = {"id": "admin-1", "username": "admin", "display_name": "Admin"}
@pytest.fixture(autouse=True)
def _draco_env(monkeypatch, tmp_path):
monkeypatch.setenv("DRACO_SESSION_SECRET", "secret-de-test")
monkeypatch.setattr(draco_session, "REVOKED_FILE",
tmp_path / "draco_revoked.json")
draco_session._revoked = None
yield
draco_session._revoked = None
# ── Jeton : émission / vérification ─────────────────────────────────────
def test_roundtrip_claims_minimaux():
token, jti, exp = draco_session.issue_token(USER)
claims = draco_session.verify_token(token)
assert claims is not None
assert claims["sub"] == "admin-1"
assert claims["name"] == "Admin"
assert claims["jti"] == jti
assert exp - claims["iat"] == 12 * 3600 # durée de vie du design §5.2
assert "role" not in claims # aucun rôle dans le jeton
def test_jeton_falsifie_rejete():
token, _j, _e = draco_session.issue_token(USER)
assert draco_session.verify_token(token + "x") is None
autre = jose_jwt.encode({"sub": "admin-1"}, "mauvais-secret",
algorithm="HS256")
assert draco_session.verify_token(autre) is None
def test_jeton_expire_rejete():
now = int(time.time())
token = jose_jwt.encode(
{"sub": "admin-1", "name": "Admin", "iat": now - 7200,
"exp": now - 3600, "jti": "ancien"},
"secret-de-test", algorithm="HS256")
assert draco_session.verify_token(token) is None
def test_rotation_secret_precedent_accepte(monkeypatch):
token = jose_jwt.encode(
{"sub": "admin-1", "name": "Admin", "iat": int(time.time()),
"exp": int(time.time()) + 3600, "jti": "rotation"},
"ancien-secret", algorithm="HS256")
assert draco_session.verify_token(token) is None # pas encore configuré
monkeypatch.setenv("DRACO_SESSION_SECRET_PREV", "ancien-secret")
assert draco_session.verify_token(token)["jti"] == "rotation"
def test_revocation():
token, jti, exp = draco_session.issue_token(USER)
assert draco_session.verify_token(token) is not None
draco_session.revoke(jti, exp)
assert draco_session.verify_token(token) is None
assert {"jti": jti, "exp": exp} in draco_session.revoked_list()
def test_inerte_sans_secret(monkeypatch):
monkeypatch.delenv("DRACO_SESSION_SECRET")
assert draco_session.enabled() is False
assert draco_session.verify_token("peu-importe") is None
def test_fenetre_de_renouvellement():
assert draco_session.needs_renewal({"exp": int(time.time()) + 1800})
assert not draco_session.needs_renewal({"exp": int(time.time()) + 12 * 3600})
# ── return_to : liste blanche ────────────────────────────────────────────
@pytest.mark.parametrize("url,ok", [
("https://tiktak.dracodev.net/?task=3", True),
("https://og.dracodev.net/", True),
("https://dracodev.net/x", True),
("http://tiktak.dracodev.net/", False), # https seulement
("https://evil.com/", False),
("https://tiktak.dracodev.net.evil.com/", False),
("https://notdracodev.net/", False),
("", False),
])
def test_validate_return_to(url, ok):
assert (draco_session.validate_return_to(url) is not None) == ok
# ── Intégration : connexion / déconnexion / renouvellement ──────────────
def _login(client):
r = client.post("/api/auth/login",
json={"username": "admin", "password": "chab30"})
assert r.status_code == 200, r.text
return r
def _draco_cookie(response):
for raw in response.headers.get_list("set-cookie"):
if raw.startswith("draco_session="):
return raw
return None
def test_login_pose_le_cookie(admin_client):
r = _login(admin_client)
cookie = _draco_cookie(r)
assert cookie is not None
assert "Domain=.dracodev.net" in cookie
assert "HttpOnly" in cookie and "SameSite=lax" in cookie
token = cookie.split(";")[0].split("=", 1)[1]
assert draco_session.verify_token(token)["sub"] == "admin-1"
def test_login_sans_secret_aucun_cookie(admin_client, monkeypatch):
monkeypatch.delenv("DRACO_SESSION_SECRET")
r = _login(admin_client)
assert _draco_cookie(r) is None
def _token_depuis_login(client):
# Le jar du TestClient refuse le cookie Domain=.dracodev.net (hôte
# de test différent) : on lit le jeton dans l'en-tête set-cookie.
cookie = _draco_cookie(_login(client))
assert cookie is not None
return cookie.split(";")[0].split("=", 1)[1]
def test_logout_revoque_le_jti(admin_client):
token = _token_depuis_login(admin_client)
assert draco_session.verify_token(token)
r = admin_client.post("/api/auth/logout",
headers={"Cookie": f"draco_session={token}"})
assert r.status_code == 200, r.text
assert draco_session.verify_token(token) is None # révoqué
def test_renouvellement_glissant(admin_client):
_login(admin_client)
# Cookie draco presque expiré (dans la fenêtre des 2 h) :
now = int(time.time())
vieux = jose_jwt.encode(
{"sub": "admin-1", "name": "Admin", "iat": now - 11 * 3600,
"exp": now + 600, "jti": "vieux-jti"},
"secret-de-test", algorithm="HS256")
admin_client.cookies.set("draco_session", vieux)
r = admin_client.get("/api/auth/me")
assert r.status_code == 200, r.text
cookie = _draco_cookie(r)
assert cookie is not None # réémis
nouveau = cookie.split(";")[0].split("=", 1)[1]
assert draco_session.verify_token(nouveau)["jti"] != "vieux-jti"
def test_pas_de_renouvellement_si_frais(admin_client):
token = _token_depuis_login(admin_client)
admin_client.cookies.set("draco_session", token) # cookie frais porté
r = admin_client.get("/api/auth/me")
assert r.status_code == 200
assert _draco_cookie(r) is None # cookie frais : rien
def test_sso_authorize_liste_blanche(client):
r = client.get("/sso/authorize",
params={"return_to": "https://tiktak.dracodev.net/?task=3"},
follow_redirects=False)
assert r.status_code == 302
assert "return_to=" in r.headers["location"]
r = client.get("/sso/authorize",
params={"return_to": "https://evil.com/phish"},
follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/" # refusé, pas de relais
# ── Validation locale (ACC-3.2, #9) ──────────────────────────────────────
def test_me_avec_cookie_draco_seul(admin_client):
# Aucune session native : le cookie partagé suffit (sub = id).
token, _jti, _exp = draco_session.issue_token(USER)
admin_client.cookies.set("draco_session", token)
r = admin_client.get("/api/auth/me")
assert r.status_code == 200, r.text
assert r.json()["username"] == "admin"
def test_me_draco_revoque_refuse(admin_client):
token, jti, exp = draco_session.issue_token(USER)
draco_session.revoke(jti, exp)
admin_client.cookies.set("draco_session", token)
r = admin_client.get("/api/auth/me")
assert r.status_code == 401
def test_me_draco_sub_inconnu_refuse(admin_client):
token, _j, _e = draco_session.issue_token(
{"id": "personne-99", "username": "x", "display_name": "X"})
admin_client.cookies.set("draco_session", token)
r = admin_client.get("/api/auth/me")
assert r.status_code == 401
def test_me_draco_inerte_sans_secret(admin_client, monkeypatch):
token, _j, _e = draco_session.issue_token(USER)
monkeypatch.delenv("DRACO_SESSION_SECRET")
admin_client.cookies.set("draco_session", token)
r = admin_client.get("/api/auth/me")
assert r.status_code == 401
def test_revocations_protegees_par_cle_machine(admin_client, monkeypatch):
token, jti, exp = draco_session.issue_token(USER)
draco_session.revoke(jti, exp)
r = admin_client.get("/api/sso/revocations")
assert r.status_code == 404 # clé non configurée
monkeypatch.setenv("DRACO_SSO_MACHINE_KEY", "cle-machine")
r = admin_client.get("/api/sso/revocations")
assert r.status_code == 401 # en-tête absent
r = admin_client.get("/api/sso/revocations",
headers={"X-Draco-Machine-Key": "cle-machine"})
assert r.status_code == 200
assert {"jti": jti, "exp": exp} in r.json()["revoked"]
+131
View File
@@ -0,0 +1,131 @@
"""Références croisées des notes — Fusion #215 (ACC-1.3).
Parseur d'URL profondes (formes figées de dracodev-commons
``contrats/liens-profonds.md``), stockage JSON par utilisateur et
endpoints ``/api/refs`` (création par collage d'URL, liste, retrait).
"""
import pytest
from backend.external_refs import (
add_ref,
delete_ref,
get_ref,
list_refs,
parse_deep_link,
)
@pytest.fixture()
def refs_env(tmp_path, monkeypatch):
monkeypatch.setenv("OBSIGATE_EXTERNAL_REFS_DIR", str(tmp_path / "refs"))
return tmp_path
# ------------------------------------------------------------ parseur
@pytest.mark.parametrize("url,attendu", [
("https://tiktak.dracodev.net/?task=42",
("tiktak", "task", "42", "TikTak · tâche 42")),
("https://tiktak.dracodev.net/?list=7",
("tiktak", "list", "7", "TikTak · liste 7")),
("https://og.dracodev.net/#file=home-bruno:Projets%2FAlpha.md",
("obsigate", "note", "home-bruno:Projets/Alpha.md", "ObsiGate · Alpha.md")),
("https://flowdeck.dracodev.net/pages/15",
("flowdeck", "page", "15", "Flowdeck · page 15")),
("https://flowdeck.dracodev.net/collections/3",
("flowdeck", "project", "3", "Flowdeck · projet 3")),
("https://git.dracodev.net/Projets/fusion-trois-apps/issues/5",
("kanbante", "card", "Projets/fusion-trois-apps#5", "Kanbante · fusion-trois-apps #5")),
])
def test_parse_formes_reconnues(url, attendu):
assert parse_deep_link(url) == attendu
@pytest.mark.parametrize("url", [
"https://example.com/?task=1",
"https://og.dracodev.net/#file=sansdeuxpoints",
"https://og.dracodev.net/",
"https://flowdeck.dracodev.net/autre/12",
"https://git.dracodev.net/Projets/fusion-trois-apps",
"pas une url",
"",
])
def test_parse_formes_refusees(url):
assert parse_deep_link(url) is None
# ------------------------------------------------------------ stockage
def test_stockage_cycle(refs_env):
ref = add_ref("alice", "TestVault", "Note A.md", "tiktak", "task", "42",
"https://tiktak.dracodev.net/?task=42", "TikTak · tâche 42")
assert ref["owner_type"] == "obsigate.note"
assert ref["owner_id"] == "TestVault:Note A.md"
assert get_ref("alice", ref["id"])["target_id"] == "42"
assert [r["id"] for r in list_refs("alice", "TestVault", "Note A.md")] == [ref["id"]]
assert list_refs("alice", "TestVault", "Autre.md") == []
assert list_refs("bob", "TestVault", "Note A.md") == [] # par utilisateur
removed = delete_ref("alice", ref["id"])
assert removed["id"] == ref["id"]
assert delete_ref("alice", ref["id"]) is None
assert list_refs("alice", "TestVault", "Note A.md") == []
# ------------------------------------------------------------ API
def test_api_cycle_complet(client, refs_env):
r = client.post("/api/refs", json={
"vault": "TestVault", "path": "Note A.md",
"url": "https://flowdeck.dracodev.net/collections/3",
})
assert r.status_code == 201, r.text
ref = r.json()
assert ref["target_app"] == "flowdeck"
assert ref["target_type"] == "project"
assert ref["label"] == "Flowdeck · projet 3"
r = client.post("/api/refs", json={
"vault": "TestVault", "path": "Note A.md",
"url": "https://tiktak.dracodev.net/?task=9", "label": "Ma tâche",
})
assert r.status_code == 201, r.text
assert r.json()["label"] == "Ma tâche"
r = client.get("/api/refs", params={"vault": "TestVault", "path": "Note A.md"})
assert r.status_code == 200
assert [x["target_app"] for x in r.json()["refs"]] == ["flowdeck", "tiktak"]
r = client.delete(f"/api/refs/{ref['id']}")
assert r.status_code == 200
r = client.get("/api/refs", params={"vault": "TestVault", "path": "Note A.md"})
assert [x["target_app"] for x in r.json()["refs"]] == ["tiktak"]
def test_api_url_inconnue_422(client, refs_env):
r = client.post("/api/refs", json={
"vault": "TestVault", "path": "Note A.md",
"url": "https://example.com/whatever",
})
assert r.status_code == 422
def test_api_delete_introuvable_404(client, refs_env):
r = client.delete("/api/refs/inexistant")
assert r.status_code == 404
def test_api_vault_sans_acces_403(admin_client, refs_env):
"""Auth activée : un utilisateur limité à TestVault reçoit 403 ailleurs."""
r = admin_client.post("/api/auth/login",
json={"username": "normaluser", "password": "normal123"})
assert r.status_code == 200, r.text
headers = {"Authorization": f"Bearer {r.json()['access_token']}"}
r = admin_client.get("/api/refs", headers=headers,
params={"vault": "VaultFantome", "path": "x.md"})
assert r.status_code == 403
r = admin_client.get("/api/refs", headers=headers,
params={"vault": "TestVault", "path": "x.md"})
assert r.status_code == 200