Merge pull request 'Recherche partout (#68) : page de résultats unifiée + endpoint JSON /api/search' (#35) from recherche-partout-68 into main
CI / test (push) Failing after 2s

This commit was merged in pull request #35.
This commit is contained in:
2026-10-28 12:40:46 -04:00
8 changed files with 1044 additions and 1 deletions
+68
View File
@@ -390,6 +390,19 @@ class AuthMiddleware(BaseHTTPMiddleware):
if (path.startswith("/mcp") or path.startswith("/api/")) \
and _bearer_user(request) is not None:
return await call_next(request)
# Recherche partout (#68) : /api/search accepte aussi le jeton
# SSO draco_session (mono-utilisateur : un jeton valide désigne
# l'opérateur), comme les API de recherche des sœurs. Les
# preflights OPTIONS ne portent jamais de cookies : ils sont
# répondus par RechercheCorsMiddleware avant d'arriver ici.
if path == "/api/search":
try:
from app import draco
if draco.claims_for(request) is not None:
return await call_next(request)
except Exception:
pass
# API consumers get a plain 401 (no browser prompt, no
# redirect they cannot follow); browsers get the login page.
if path.startswith("/api/") or "text/html" not in request.headers.get(
@@ -408,6 +421,15 @@ class AuthMiddleware(BaseHTTPMiddleware):
app.add_middleware(AuthMiddleware)
# Recherche partout (#68) : CORS bornée pour GET /api/search — ajouté
# après AuthMiddleware donc exécuté avant lui (dernier ajouté = le
# plus externe) : les preflights OPTIONS sont répondus ici, avant
# tout refus d'authentification.
from app.recherche import RechercheCorsMiddleware
app.add_middleware(RechercheCorsMiddleware)
def _safe_next(raw: str) -> str:
"""Only local paths may be a post-login destination."""
if raw.startswith("/") and not raw.startswith("//") and "://" not in raw:
@@ -4311,6 +4333,51 @@ async def search_page(request: Request, q: str = ""):
)
@app.get("/recherche", response_class=HTMLResponse)
async def recherche_page(request: Request, q: str = ""):
"""Page de résultats unifiée « Rechercher partout » (fusion #68).
Coquille seule : les résultats sont chargés côté client par
static/js/recherche.js. La page /search native reste inchangée.
"""
return render(request, "recherche.html", q=q.strip())
@app.get("/api/search")
async def recherche_api(request: Request, q: str = "", limit: int = 20):
"""Recherche JSON pour la page « Rechercher partout » (fusion #68).
Même source que la page /search : recherche Gitea (titres + corps,
tous états, tous projets du workspace), groupée par dépôt avec la
colonne du tableau. Auth : voir AuthMiddleware (session, Basic,
jeton API, ou jeton SSO draco_session pour ce chemin). Les corps
sont tronqués à 160 caractères ; seuls les champs utiles sont
exposés (voir app/recherche.py).
"""
from app.recherche import groupes_vers_json
q = q.strip()
limit = max(1, min(limit, 50))
if not q:
return {"query": q, "groups": [], "total": 0}
workspace, _role = _current_workspace(request)
conn_ids = (
models.workspace_connection_ids(workspace["id"])
if workspace
else [registry.default_id]
)
results: list[dict] = []
for cid in conn_ids:
try:
found = await _forge_client(cid).search_issues(q=q, state="all")
results.extend(found)
except Exception as exc:
logger.debug("search failed for conn %s: %s", cid, exc)
groups = _group_issue_results(results)
data, total = groupes_vers_json(groups, limit)
return {"query": q, "groups": data, "total": total}
@app.get("/my", response_class=HTMLResponse)
async def my_issues(request: Request):
"""Open issues assigned to the token owner, per connection."""
@@ -6914,3 +6981,4 @@ async def readiness():
},
status_code=200 if ready else 503,
)
+217
View File
@@ -0,0 +1,217 @@
"""Recherche fédérée (fusion-trois-apps #68) : JSON de recherche,
CORS bornée et conversion des groupes d'issues.
Ce module ne dépend d'aucun autre module applicatif à l'import (les
résolveurs sont appelés paresseusement) : les fonctions pures sont
testables sans base ni serveur.
- ``GET /api/search`` (route dans ``app/main.py``) réutilise la
recherche Gitea de la page ``/search`` (titres + corps, tous états,
tous projets du workspace) et la regroupe par dépôt.
- La CORS bornée n'autorise que ``GET /api/search``, et seulement
pour les origines sœurs résolues par ``app.config`` (explicite >
hôte de la requête > dérivation > défaut officiel — donc ``-dev``
reste ``-dev``), jamais ``*``. Le middleware est un ASGI pur :
il répond lui-même aux preflights OPTIONS (qui ne portent jamais
de cookies, donc qu'``AuthMiddleware`` refuserait sinon) et ajoute
les en-têtes aux réponses de recherche, sans toucher aux autres
routes.
"""
from __future__ import annotations
from urllib.parse import urlparse
SEARCH_PATH = "/api/search"
_SNIPPET_LEN = 160
PROD_ORIGINS = frozenset({
"https://og.dracodev.net",
"https://flowdeck.dracodev.net",
"https://tiktak.dracodev.net",
"https://kanbante.dracodev.net",
})
DEV_ORIGINS = frozenset({
"https://og-dev.dracodev.net",
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
})
# Clés du dictionnaire rendu par render() (app/main.py).
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
def is_dev_host(hostname: str | None) -> bool:
"""Même règle que la barre v0.1.6 (voir static/js/recherche.js)."""
if not hostname:
return False
h = hostname.lower()
if not h.endswith(".dracodev.net"):
return False
first = h.split(".")[0]
return len(first) > 4 and first.endswith("-dev")
def origin_of(url: str | None) -> str | None:
"""Origine (schéma + hôte) d'une URL, sans chemin ni ``/`` final."""
if not url:
return None
parsed = urlparse(url.strip())
if parsed.scheme not in ("http", "https") or not parsed.hostname:
return None
host = parsed.hostname.lower()
port = f":{parsed.port}" if parsed.port else ""
return f"{parsed.scheme}://{host}{port}"
def sister_origins(
resolved: dict | None = None, request_host: str | None = None
) -> frozenset:
"""Origines autorisées pour la lecture cross-origin de /api/search.
``resolved`` : dictionnaire ``draco_urls`` rendu par ``render()``.
Sans argument, le résolveur est appelé (import paresseux) avec
l'hôte de la requête quand il est connu — ce middleware s'exécute
avant tout poseur de contexte d'hôte, et sans lui la résolution
retomberait sur la configuration seule et raterait l'environnement
``-dev`` ; en cas d'échec, repli sur prod + dev officiels
(premier parti uniquement).
"""
if resolved is None:
try:
from app.config import get_settings, resoudre_urls_draco
urls = resoudre_urls_draco(hote_requete=request_host, base=get_settings())
resolved = {
"obsigate": urls.sso_url,
"flowdeck": urls.flowdeck_url,
"tiktak": urls.tiktak_url,
"kanbante": urls.public_url,
}
except Exception:
return PROD_ORIGINS | DEV_ORIGINS
origins = set()
for key in _SOEUR_KEYS:
origin = origin_of((resolved or {}).get(key))
if origin:
origins.add(origin)
return frozenset(origins)
def cors_headers_for(
origin: str | None, resolved: dict | None = None, request_host: str | None = None
) -> dict | None:
"""En-têtes CORS d'une réponse de recherche, ou None si refusée."""
if not origin:
return None
if origin.strip() not in sister_origins(resolved, request_host):
return None
return {
"Access-Control-Allow-Origin": origin.strip(),
"Access-Control-Allow-Credentials": "true",
"Vary": "Origin",
}
def preflight_headers(
origin: str | None, resolved: dict | None = None, request_host: str | None = None
) -> dict | None:
"""En-têtes d'une réponse de preflight OPTIONS pour /api/search."""
headers = cors_headers_for(origin, resolved, request_host)
if headers is None:
return None
headers.update({
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Accept, Content-Type, Authorization",
"Access-Control-Max-Age": "600",
})
return headers
def groupes_vers_json(groups, limit: int = 20):
"""Convertit la sortie de ``_group_issue_results`` en JSON public.
Seuls les champs utiles sont exposés (numéro, titre, état, corps
tronqué à 160 caractères, labels, colonne). Retourne
``(groupes, total)`` où ``total`` est le nombre de lignes
retournées (plafonné à ``limit``).
"""
out: list[dict] = []
total = 0
restant = max(1, min(int(limit), 50))
for group in groups or []:
rows: list[dict] = []
for row in (group or {}).get("rows", []) or []:
if restant <= 0:
break
issue = (row or {}).get("issue", {}) or {}
labels = []
for label in issue.get("labels") or []:
if isinstance(label, dict):
labels.append({"name": label.get("name"), "color": label.get("color")})
rows.append({
"issue": {
"number": issue.get("number"),
"title": issue.get("title"),
"state": issue.get("state"),
"body": (issue.get("body") or "")[:_SNIPPET_LEN],
"labels": labels,
},
"column": (row or {}).get("column"),
})
restant -= 1
total += 1
if rows:
out.append({"full_name": (group or {}).get("full_name"), "rows": rows})
if restant <= 0:
break
return out, total
class RechercheCorsMiddleware:
"""ASGI pur : CORS bornée pour ``GET /api/search`` (voir module)."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope.get("type") != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
headers_in = {
k.decode("latin-1").lower(): v.decode("latin-1")
for k, v in scope.get("headers", [])
}
origin = headers_in.get("origin", "")
request_host = (
headers_in.get("host", "") or headers_in.get("x-forwarded-host", "")
).split(",")[0].strip()
if path == SEARCH_PATH and scope.get("method") == "OPTIONS":
preflight = preflight_headers(origin, request_host=request_host)
if preflight is not None:
body = b""
headers_out = [(k.lower().encode("latin-1"), v.encode("latin-1"))
for k, v in preflight.items()]
headers_out.append((b"content-length", b"0"))
await send({"type": "http.response.start", "status": 204,
"headers": headers_out})
await send({"type": "http.response.body", "body": body})
return
if path != SEARCH_PATH:
await self.app(scope, receive, send)
return
async def send_with_cors(message):
if message["type"] == "http.response.start":
cors = cors_headers_for(origin, request_host=request_host)
if cors is not None:
message["headers"] = list(message.get("headers", [])) + [
(k.lower().encode("latin-1"), v.encode("latin-1"))
for k, v in cors.items()
]
await send(message)
await self.app(scope, receive, send_with_cors)
+2 -1
View File
@@ -94,7 +94,7 @@
};
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
if (act === "rail") { var b = document.getElementById("side-collapse"); if (b) b.click(); return; }
if (act === "palette") { var p = document.querySelector(".palette-trigger"); if (p) { p.click(); } else { dire("Recherche & commandes : Ctrl+K."); } return; }
if (act === "palette") { location.href = "/recherche"; return; }
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
/* Correctif fusion #70 : action "menu" supprimée avec le bouton trois points (console agent dans le menu de la marque). */
if (act === "spark") {
@@ -113,3 +113,4 @@
<script defer src="/static/js/draco-barre.js?v={{ version }}"></script>
<script>window.DRACO_PANNEAU_CONFIG = { api: "{{ agent_url }}" };</script>
<script defer src="/static/js/draco-panneau.js?v={{ version }}"></script>
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rechercher partout — Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔍</text></svg>">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/recherche.css">
</head>
<body>
{% include "_header.html" %}
<main class="rp-page" id="recherche" data-host="kanbante"
data-base-obsigate="{{ draco_urls.obsigate }}"
data-base-flowdeck="{{ draco_urls.flowdeck }}"
data-base-tiktak="{{ draco_urls.tiktak }}"
data-base-kanbante="{{ draco_urls.kanbante }}">
<h1>Rechercher partout</h1>
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat.</p>
<div class="rp-form">
<input id="rp-q" class="rp-input" type="search" name="q" value="{{ q }}"
placeholder="Rechercher partout…" autocomplete="off" autofocus>
</div>
<section class="rp-section" data-rp-section="obsigate">
<div class="rp-head"><h2>ObsiGate — notes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="flowdeck">
<div class="rp-head"><h2>Flowdeck — pages</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="tiktak">
<div class="rp-head"><h2>TikTak — tâches</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="kanbante">
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
</main>
<footer>
Kanbante v{{ version }} · recherche partout
</footer>
<script src="/static/js/recherche.js" defer></script>
</body>
</html>