From 0de3b6586989c562e799b44212ca9ac1b479d831 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:08:14 -0400 Subject: [PATCH] =?UTF-8?q?Recherche=20partout=20(#68)=20:=20routes=20/rec?= =?UTF-8?q?herche=20+=20/api/search,=20draco=20sur=20/api/search,=20CORS?= =?UTF-8?q?=20born=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/app/main.py b/app/main.py index 4764288..414c351 100644 --- a/app/main.py +++ b/app/main.py @@ -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, ) +