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
CI / test (push) Failing after 2s
This commit was merged in pull request #35.
This commit is contained in:
+68
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user