diff --git a/backend/main.py b/backend/main.py index 44e0c16..b236bf3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -531,6 +531,15 @@ app.add_middleware( allow_headers=["*"], ) +# Recherche partout (#68) : CORS bornée pour GET /api/search — ajoutée +# après le CORSMiddleware global donc exécutée avant lui (dernier +# ajouté = le plus externe) : les preflights OPTIONS sont répondus +# ici, et les réponses de /api/search reçoivent les en-têtes sœurs. +# La politique same-origin explicite du middleware global reste la +# règle par défaut pour tout le reste. +from backend.recherche_cors import RechercheCorsMiddleware + +app.add_middleware(RechercheCorsMiddleware) # Auth router # Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c). from backend.ai_routes import router as ai_router @@ -950,6 +959,71 @@ if FRONTEND_DIR.exists(): return HTMLResponse(content=_html_with_nonce(request, "admin.html"), headers={"Cache-Control": "no-cache"}) raise HTTPException(status_code=404, detail="Admin page not found") + @app.get("/recherche", response_class=HTMLResponse) + async def serve_recherche_page(request: Request): + """Page « Rechercher partout » (fusion #68) — déclarée AVANT le + catch-all SPA comme /admin.html, sinon index.html serait servi. + + frontend/recherche.html est servi avec les jetons {{URL_*}} + remplacés par urls_publiques() (même contrat que le fragment + de barre) et le nonce CSP frais ; la réponse pose sa propre + CSP (préservée par SecurityHeadersMiddleware, qui ne la pose + que si elle est absente) dont le connect-src autorise les + origines sœurs — sinon le navigateur bloquerait les appels + sœurs avant même CORS. La CSP par défaut (same-origin) reste + inchangée pour toutes les autres pages. + """ + import json as _json + + from backend.csp import inject_csp_nonce + from backend.draco_config import urls_publiques + from backend.recherche_cors import origin_of + + recherche_file = FRONTEND_DIR / "recherche.html" + if not recherche_file.exists(): + raise HTTPException(status_code=404, detail="Recherche page not found") + html = recherche_file.read_text(encoding="utf-8") + urls = urls_publiques() + for jeton, cle in ( + ("{{URL_OBSIGATE}}", "obsigate"), + ("{{URL_FLOWDECK}}", "flowdeck"), + ("{{URL_TIKTAK}}", "tiktak"), + ("{{URL_KANBANTE}}", "kanbante"), + ): + html = html.replace(jeton, urls.get(cle, "")) + script = ( + "" + ) + if "" in html: + html = html.replace("", script + "", 1) + else: + html = script + html + html = inject_csp_nonce(html, request.state.csp_nonce) + soeurs = " ".join(sorted({ + origin_of(urls.get(cle, "")) or "" + for cle in ("obsigate", "flowdeck", "tiktak", "kanbante") + } - {""})) + csp = ( + "default-src 'self'; " + f"script-src 'self' 'nonce-{request.state.csp_nonce}'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + f"connect-src 'self' {soeurs}; " + "font-src 'self' data:; " + "worker-src 'self' blob:; " + "frame-src 'self'; " + "object-src 'none'; " + "base-uri 'self'; " + "form-action 'self'; " + "frame-ancestors 'self';" + ) + return HTMLResponse( + content=html, + headers={"Cache-Control": "no-cache", "Content-Security-Policy": csp}, + ) + @app.get("/{full_path:path}") async def serve_spa(request: Request, full_path: str): """Serve the SPA index.html for all non-API routes."""