diff --git a/app/main.py b/app/main.py index da57dc7..df8567c 100644 --- a/app/main.py +++ b/app/main.py @@ -250,6 +250,14 @@ app.add_middleware( allow_credentials=True, ) +# Recherche partout (#68) : CORS bornée pour GET /api/search — ajoutée +# après CORSMiddleware donc exécutée avant lui (dernier ajouté = le +# plus externe) : les preflights OPTIONS sont répondus ici, avant +# tout refus d'origine. La politique CORS globale est inchangée. +from app.middleware.recherche_cors import RechercheCorsMiddleware + +app.add_middleware(RechercheCorsMiddleware) + app.include_router(auth.router) app.include_router(sso_router) app.include_router(dashboard.router) @@ -427,3 +435,4 @@ def http_exception_handler(request: Request, exc: _StarHTTPException): return problem_response(request, exc) from fastapi.responses import JSONResponse return JSONResponse({"detail": detail if isinstance(detail, str) else str(detail)}, status_code=status) + diff --git a/app/middleware/recherche_cors.py b/app/middleware/recherche_cors.py new file mode 100644 index 0000000..ed2f323 --- /dev/null +++ b/app/middleware/recherche_cors.py @@ -0,0 +1,190 @@ +"""CORS bornée pour la recherche fédérée (fusion-trois-apps #68). + +La page « Rechercher partout » (``/recherche``) appelle, depuis le +navigateur, ``GET /api/search`` sur chaque application sœur. Sans +en-têtes CORS, le navigateur refuse la lecture des réponses +cross-origin ; sans borne, on ouvrirait la lecture à tout le web. +Ce module n'autorise donc que : + +- le chemin ``/api/search`` (jamais les autres routes, jamais les + écritures) ; +- les origines sœurs : celles résolues par + ``app.services.draco_urls.toutes()`` (explicite > hôte > dérivation + > défaut officiel), **plus** les origines prod ou ``-dev`` + officielles correspondant à l'hôte de la requête — ce second + ensemble est nécessaire car ce middleware s'exécute avant le + middleware CSP qui pose le contexte d'hôte de la requête pour + ``draco_urls`` ; sans lui, une page servie par ``flowdeck-dev`` + verrait ses sœurs ``-dev`` refusées quand la configuration seule + ne les déclare pas ; +- avec credentials (le cookie SSO ``draco_session``, accepté par + ``/api/search`` depuis cette livraison, ou la session native). + +Jamais ``*``. Le middleware est un ASGI pur : il répond lui-même +aux preflights OPTIONS (qui ne portent jamais de cookies) et ajoute +les en-têtes aux réponses de recherche, sans toucher aux autres +routes ni à la politique CORS globale de l'application. +""" + +from __future__ import annotations + +from urllib.parse import urlparse + +SEARCH_PATH = "/api/search" + +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 de draco_urls.toutes() qui sont des applications sœurs. +_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().split(":")[0] + if not h.endswith(".dracodev.net"): + return False + first = h.split(".")[0] + return len(first) > 4 and first.endswith("-dev") + + +def is_prod_host(hostname: str | None) -> bool: + """Hôte dracodev.net qui n'est ni ``-dev`` ni un piège de suffixe.""" + if not hostname: + return False + h = hostname.lower().split(":")[0] + if not h.endswith(".dracodev.net"): + return False + first = h.split(".")[0] + return len(first) > 0 and not 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 rendu par ``draco_urls.toutes()``. + Sans argument, le résolveur est appelé (import paresseux) ; en cas + d'échec, repli sur prod + dev officiels (premier parti uniquement). + ``request_host`` ajoute l'ensemble officiel de l'environnement de + la requête (voir le docstring du module). + """ + origins = set() + if resolved is None: + try: + from app.services import draco_urls + + resolved = draco_urls.toutes() + except Exception: + resolved = {} + for key in _SOEUR_KEYS: + origin = origin_of((resolved or {}).get(key)) + if origin: + origins.add(origin) + if is_dev_host(request_host): + origins |= DEV_ORIGINS + elif is_prod_host(request_host): + origins |= PROD_ORIGINS + if not origins: + return PROD_ORIGINS | DEV_ORIGINS + 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 + + +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("x-forwarded-host", "") or headers_in.get("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: + 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": b""}) + 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) diff --git a/app/middleware/security.py b/app/middleware/security.py index 1a65711..12b647f 100644 --- a/app/middleware/security.py +++ b/app/middleware/security.py @@ -174,9 +174,29 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware): request.headers.get("host", "")) agent = re.sub(r"[^0-9A-Za-z.\-:/]", "", draco_urls.origine(draco_urls.agent_url())) - response.headers[self.CSP_HEADER] = self.CSP_VALUE.format( + csp = self.CSP_VALUE.format( nonce=nonce, host=host, agent=agent ) + # Recherche partout (#68) : la page /recherche appelle les + # API de recherche des sœurs depuis le navigateur — sa + # connect-src doit les nommer, sinon le navigateur bloque + # les appels avant même CORS. Les autres pages gardent la + # CSP inchangée. `draco_urls` est déjà importé dans ce + # dispatch (contexte d'hôte posé plus haut). + if request.url.path == "/recherche": + try: + soeurs = " ".join(sorted({ + draco_urls.origine(draco_urls.toutes()[k]) + for k in ("obsigate", "tiktak", "kanbante") + })) + csp = csp.replace( + "connect-src 'self' " + agent + " ", + "connect-src 'self' " + agent + " " + soeurs + " ", + 1, + ) + except Exception: + pass + response.headers[self.CSP_HEADER] = csp return response @@ -288,3 +308,4 @@ class RateLimitMiddleware(BaseHTTPMiddleware): for k in expired: del self._store[k] self._last_prune = now + diff --git a/app/routers/search.py b/app/routers/search.py index ddb9be3..28e3e3e 100644 --- a/app/routers/search.py +++ b/app/routers/search.py @@ -2,10 +2,19 @@ ``GET /api/search?q=`` backs the Ctrl+K command palette. Returns matching editor pages and databases scoped to the current user's accessible workspaces. + +Recherche partout (fusion-trois-apps #68) : la même route sert la page +unifiée (``GET /recherche``, coquille dont les résultats sont chargés +côté client) et accepte, en plus de la session native, le jeton SSO +``draco_session`` (validateur local ``app.services.draco``). Sans +session ni jeton valide, 401 — avant cette livraison, l'absence de +session donnait une recherche SANS périmètre (``user_id=None`` → ``1=1`` +dans ``_scope_where``) : corrigé ici. """ from __future__ import annotations -from fastapi import APIRouter, Query, Request +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import HTMLResponse from app.auth.session import SessionManager from app.services.search import search as search_service @@ -17,6 +26,18 @@ router = APIRouter(tags=["search"]) def search(request: Request, q: str = Query(default="")): user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user_id = user.get("id") if user and user.get("id") else None + if user_id is None: + # Repli SSO draco_session : un jeton valide désigne l'utilisateur + # lié, ou l'unique utilisateur actif (cas mono-utilisateur). + try: + from app.services import draco as draco_service + + draco_user = draco_service.user_for_draco(request) + user_id = draco_user.get("id") if draco_user else None + except Exception: + user_id = None + if user_id is None: + raise HTTPException(status_code=401, detail="Authentication required") data = search_service(q, user_id=user_id) return { @@ -25,3 +46,24 @@ def search(request: Request, q: str = Query(default="")): "collections": data["collections"], "total": len(data["pages"]) + len(data["collections"]), } + + +@router.get("/recherche", response_class=HTMLResponse) +def recherche_page(request: Request, q: str = Query(default="")): + """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 (API de recherche de chaque app, en + parallèle, états par application). La palette Ctrl+K native et + ``GET /api/search`` restent inchangés par ailleurs. + """ + from app.services import draco_urls + from app.templating import ENV + + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + bases = draco_urls.toutes() + template = ENV.get_template("recherche.html") + return HTMLResponse(template.render( + request=request, q=q.strip(), user=user, + bases={k: bases[k] for k in ("obsigate", "flowdeck", "tiktak", "kanbante")}, + )) diff --git a/app/templates/_draco_bar.html b/app/templates/_draco_bar.html index 5f181e6..79ad10e 100644 --- a/app/templates/_draco_bar.html +++ b/app/templates/_draco_bar.html @@ -86,7 +86,7 @@ if (b) { b.click(); } else { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); } return; } - if (act === "palette") { dire("Recherche fédérée ⌘K : arrive avec la fédération (programme Fusion)."); return; } + if (act === "palette") { location.href = "/recherche"; return; } /* v0.1.6 : le toast placeholder "fédération" est remplacé par le menu par défaut du nouveau JS — retourner false le demande explicitement (un onAction existant garde sinon la @@ -107,3 +107,4 @@ } }; + diff --git a/app/templates/recherche.html b/app/templates/recherche.html new file mode 100644 index 0000000..23fdb44 --- /dev/null +++ b/app/templates/recherche.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title_prefix %}Rechercher partout{% endblock %} +{% block content %} + +
+

Rechercher partout

+

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.

+
+ +
+
+

ObsiGate — notes

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+

Flowdeck — pages

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+

TikTak — tâches

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+

Kanbante — cartes

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/static/css/recherche.css b/static/css/recherche.css new file mode 100644 index 0000000..f8ab9d9 --- /dev/null +++ b/static/css/recherche.css @@ -0,0 +1,35 @@ +/* Recherche partout (fusion #68) — classes préfixées .rp- pour éviter + * toute collision avec les styles de l'hôte. Couleurs neutres héritées + * autant que possible : la page reste lisible en thème clair et sombre. */ +.rp-page { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; } +.rp-page h1 { font-size: 1.4rem; margin: 0 0 4px; } +.rp-hint { opacity: .7; margin: 0 0 16px; font-size: .9rem; } +.rp-form { display: flex; gap: 8px; margin-bottom: 24px; } +.rp-input { + flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px; + border: 1px solid #888; border-radius: 8px; background: inherit; color: inherit; +} +.rp-input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; } +.rp-section { margin-bottom: 28px; } +.rp-head { + display: flex; align-items: baseline; gap: 10px; + border-bottom: 1px solid #888; padding-bottom: 6px; margin-bottom: 8px; +} +.rp-head h2 { font-size: 1.05rem; margin: 0; } +.rp-count { + font-size: .8rem; font-weight: 700; background: #3b82f6; color: #fff; + border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center; +} +.rp-count:empty { display: none; } +.rp-all { margin-left: auto; font-size: .85rem; white-space: nowrap; } +.rp-state { opacity: .75; margin: 8px 0; } +.rp-error { opacity: 1; } +.rp-item { + display: block; padding: 8px 10px; border-radius: 8px; + text-decoration: none; color: inherit; +} +.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; } +.rp-item:focus-visible { outline: 2px solid #3b82f6; } +.rp-title { display: block; font-weight: 600; } +.rp-context { display: block; font-size: .8rem; opacity: .65; } +.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; } diff --git a/static/js/recherche.js b/static/js/recherche.js new file mode 100644 index 0000000..3f396bd --- /dev/null +++ b/static/js/recherche.js @@ -0,0 +1,514 @@ +/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68). + * + * Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque + * hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient + * des attributs data-* de #recherche, rendus côté serveur par l'hôte. + * + * Contrat d'API (audité, rien d'inventé) : + * - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8 + * -> { results: [{ vault, path, title, snippet }] } + * Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile). + * - Flowdeck : GET {base}/api/search?q= + * -> { pages: [{ title, excerpt, subtitle, url: "/pages/" }], + * collections: [{ title, subtitle, url: "/db/" }] } + * - TikTak : GET {base}/api/search?q= + * -> { tasks: [{ id, title, description, status, due_at }], ... } + * Lien profond vérifié : {base}search?q=&task= (page /search). + * - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison) + * -> { groups: [{ full_name, rows: [{ issue, column }] }] } + * Lien profond : {base}board/ (la carte n'a pas d'URL propre + * adressable — même limite que la page /search native de Kanbante). + * + * Résolution des bases sœurs (même ordre que la barre v0.1.6) : + * 1. data-base-* rendus par l'hôte (son propre résolveur : explicite > + * hôte de la requête > dérivation > défaut officiel) ; + * 2. window.DRACO_BAR.urls (valeur déjà résolue par la barre) ; + * 3. dérivation -dev si l'hôte courant est un hôte -dev dracodev.net ; + * 4. href des onglets .draco-apptab rendus ; + * 5. défauts officiels prod. Aucune URL inventée (pas de spark-dev). + * + * Authentification cross-origin : les appels sœurs partent avec + * credentials:"include". Le navigateur envoie alors les cookies de + * chaque origine, dont le cookie SSO partagé draco_session + * (Domain=.dracodev.net), accepté par les 4 API de recherche + * (ObsiGate et TikTak nativement, Flowdeck et Kanbante via cette + * livraison). Chaque sœur n'autorise en CORS que les origines sœurs + * (GET sur l'endpoint de recherche uniquement, pas de "*"). + * Une sœur injoignable / non autorisée ne casse pas les autres : + * sa section affiche l'erreur + un lien vers sa recherche native. + */ +(function (global) { + "use strict"; + /* globalThis : le fichier est chargé comme script classique dans les + * navigateurs, requis en CJS par les tests TikTak/Kanbante/Flowdeck, + * et importé en ESM par le test ObsiGate (frontend/js est "type": + * "module") — où `this` de niveau supérieur vaut undefined. */ + + var PROD = { + obsigate: "https://og.dracodev.net/", + flowdeck: "https://flowdeck.dracodev.net/", + tiktak: "https://tiktak.dracodev.net/", + kanbante: "https://kanbante.dracodev.net/", + }; + var DEV = { + obsigate: "https://og-dev.dracodev.net/", + flowdeck: "https://flowdeck-dev.dracodev.net/", + tiktak: "https://tiktak-dev.dracodev.net/", + kanbante: "https://kanbante-dev.dracodev.net/", + }; + var APPS = [ + { key: "obsigate", label: "ObsiGate" }, + { key: "flowdeck", label: "Flowdeck" }, + { key: "tiktak", label: "TikTak" }, + { key: "kanbante", label: "Kanbante" }, + ]; + var COLUMN_LABELS = { + backlog: "Backlog", + in_progress: "En cours", + qa: "QA", + review: "Relecture", + todo: "À faire", + done: "Terminé", + }; + var TIMEOUT_MS = 8000; + var DEBOUNCE_MS = 250; + var PER_APP_LIMIT = 8; + + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function cleanUrl(u) { + if (!u) return null; + u = String(u).trim(); + if (!u || u.indexOf("{{") !== -1) return null; + return u; + } + + function withSlash(u) { + return u.charAt(u.length - 1) === "/" ? u : u + "/"; + } + + /* Même règle que la barre v0.1.6 : première étiquette en -dev ET + * domaine dracodev.net, sinon rien (un hôte piège ne déclenche rien). */ + function isDevHost(hostname) { + if (!hostname) return false; + var h = String(hostname).toLowerCase(); + if (h.slice(-13) !== ".dracodev.net") return false; + var first = h.split(".")[0]; + return first.length > 4 && first.slice(-4) === "-dev"; + } + + /* opts : { host, explicit:{}, barUrls:{}, tabHrefs:{} } */ + function resolveBases(opts) { + opts = opts || {}; + var dev = isDevHost(opts.host); + var out = {}; + APPS.forEach(function (app) { + var key = app.key; + var u = cleanUrl(opts.explicit && opts.explicit[key]); + if (!u) u = cleanUrl(opts.barUrls && opts.barUrls[key]); + if (!u && dev) u = DEV[key]; + if (!u) u = cleanUrl(opts.tabHrefs && opts.tabHrefs[key]); + if (!u) u = PROD[key]; + out[key] = withSlash(u); + }); + return out; + } + + function searchPath(app, q) { + if (app === "obsigate") { + return "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + PER_APP_LIMIT; + } + return "api/search?q=" + encodeURIComponent(q); + } + + function snippetOf(s, n) { + s = String(s == null ? "" : s).replace(/\s+/g, " ").trim(); + return s.length > (n || 160) ? s.slice(0, n || 160) + "…" : s; + } + + /* Normalise la réponse d'une app en [{ title, snippet, context, url }]. + * base : base résolue de l'app (avec / final). q : requête (liens TikTak). */ + function normalize(app, data, base, q) { + data = data || {}; + if (app === "obsigate") { + return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) { + return { + title: r.title || r.path || "Sans titre", + snippet: snippetOf(r.snippet || ""), + context: (r.vault || "") + (r.path ? " · " + r.path : ""), + url: + base + + "#file=" + + encodeURIComponent(r.vault || "") + + ":" + + encodeURIComponent(r.path || ""), + }; + }); + } + if (app === "flowdeck") { + var items = (data.pages || []).concat(data.collections || []); + return items.slice(0, PER_APP_LIMIT).map(function (r) { + var rel = String(r.url || "").replace(/^\//, ""); + return { + title: r.title || "Sans titre", + snippet: snippetOf(r.excerpt || ""), + context: r.subtitle || (r.type === "collection" ? "Base" : "Page"), + url: base + rel, + }; + }); + } + if (app === "tiktak") { + return (data.tasks || []).slice(0, PER_APP_LIMIT).map(function (t) { + var ctx = t.status === "done" ? "Terminée" : "Active"; + if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10); + return { + title: t.title || "Sans titre", + snippet: snippetOf(t.description || ""), + context: "Tâche · " + ctx, + url: base + "search?q=" + encodeURIComponent(q || "") + "&task=" + t.id, + }; + }); + } + if (app === "kanbante") { + var rows = []; + (data.groups || []).forEach(function (g) { + (g.rows || []).forEach(function (row) { + rows.push({ group: g.full_name, row: row }); + }); + }); + return rows.slice(0, PER_APP_LIMIT).map(function (x) { + var issue = x.row.issue || {}; + var col = COLUMN_LABELS[x.row.column] || x.row.column || ""; + return { + title: "#" + issue.number + " " + (issue.title || "Sans titre"), + snippet: snippetOf(issue.body || ""), + context: x.group + (col ? " · " + col : ""), + url: base + "board/" + x.group, + }; + }); + } + return []; + } + + /* Recherche native de l'app (repli quand la section est en erreur). */ + function nativeSearchUrl(app, base, q) { + if (app === "tiktak" || app === "kanbante") { + return base + "search?q=" + encodeURIComponent(q || ""); + } + /* ObsiGate et Flowdeck n'ont pas de page de recherche adressable : + * leur recherche native vit dans la palette (Ctrl+K) de l'app. */ + return base; + } + + function nativeSearchLabel(app) { + if (app === "tiktak" || app === "kanbante") { + return "Ouvrir la recherche dans " + appLabel(app); + } + return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)"; + } + + function appLabel(key) { + for (var i = 0; i < APPS.length; i++) { + if (APPS[i].key === key) return APPS[i].label; + } + return key; + } + + var api = { + esc: esc, + isDevHost: isDevHost, + resolveBases: resolveBases, + searchPath: searchPath, + normalize: normalize, + nativeSearchUrl: nativeSearchUrl, + PROD: PROD, + DEV: DEV, + }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + global.RecherchePartout = api; + + /* ---- Partie DOM : ne s'exécute que dans un navigateur. ---- */ + if (typeof document === "undefined") return; + + function fetchSection(app, url, onDone) { + var ctrl = null; + var timer = null; + try { + ctrl = new AbortController(); + timer = setTimeout(function () { + ctrl.abort(); + }, TIMEOUT_MS); + } catch (e) { + ctrl = null; + } + var opts = { headers: { Accept: "application/json" }, credentials: "include" }; + if (ctrl) opts.signal = ctrl.signal; + fetch(url, opts) + .then(function (resp) { + if (!resp.ok) { + var err = new Error("HTTP " + resp.status); + err.status = resp.status; + throw err; + } + return resp.json(); + }) + .then(function (data) { + if (timer) clearTimeout(timer); + onDone(null, data); + }) + .catch(function (err) { + if (timer) clearTimeout(timer); + onDone(err, null); + }); + } + + function renderSection(section, state) { + /* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */ + var body = section.querySelector(".rp-body"); + var count = section.querySelector(".rp-count"); + var toutVoir = section.querySelector(".rp-all"); + toutVoir.href = nativeSearchUrl(state.app, state.base, state.q); + toutVoir.textContent = "Tout voir"; + toutVoir.title = nativeSearchLabel(state.app); + if (state.status === "loading") { + count.textContent = ""; + body.innerHTML = '

Recherche…

'; + return; + } + if (state.status === "error") { + count.textContent = ""; + body.innerHTML = + '

' + + esc(state.message) + + ' — ' + + esc(nativeSearchLabel(state.app)) + + "

"; + return; + } + if (state.status === "empty") { + count.textContent = ""; + body.innerHTML = + '

Aucun résultat pour « ' + esc(state.q) + " ».

"; + return; + } + count.textContent = String(state.items.length); + body.innerHTML = state.items + .map(function (item) { + return ( + '' + + '' + + esc(item.title) + + "" + + (item.context + ? '' + esc(item.context) + "" + : "") + + (item.snippet + ? '' + esc(item.snippet) + "" + : "") + + "" + ); + }) + .join(""); + } + + function init() { + var root = document.getElementById("recherche"); + if (!root) return; + var input = document.getElementById("rp-q"); + if (!input) return; + var hostApp = root.getAttribute("data-host") || ""; + var explicit = {}; + APPS.forEach(function (app) { + var v = root.getAttribute("data-base-" + app.key); + if (v) explicit[app.key] = v; + }); + var barUrls = {}; + try { + if (global.DRACO_BAR && global.DRACO_BAR.urls) barUrls = global.DRACO_BAR.urls; + } catch (e) {} + var tabHrefs = {}; + try { + document.querySelectorAll(".draco-apptab[data-app]").forEach(function (tab) { + var href = tab.getAttribute("href"); + if (href) tabHrefs[tab.getAttribute("data-app")] = href; + }); + } catch (e) {} + var bases = resolveBases({ + host: global.location.hostname, + explicit: explicit, + barUrls: barUrls, + tabHrefs: tabHrefs, + }); + var sections = {}; + APPS.forEach(function (app) { + var el = document.querySelector('[data-rp-section="' + app.key + '"]'); + if (el) sections[app.key] = el; + }); + + var currentQ = ""; + var runId = 0; + var debounceTimer = null; + + function runSearch(q) { + currentQ = q; + var myRun = ++runId; + if (!q) { + APPS.forEach(function (app) { + var sec = sections[app.key]; + if (!sec) return; + sec.querySelector(".rp-body").innerHTML = + '

Tapez une recherche ci-dessus.

'; + sec.querySelector(".rp-count").textContent = ""; + sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], ""); + }); + return; + } + APPS.forEach(function (app) { + var sec = sections[app.key]; + if (!sec) return; + renderSection(sec, { status: "loading", app: app.key, base: bases[app.key], q: q }); + var url; + if (app.key === hostApp) { + /* Même origine : pas de CORS, pas de CSP cross-origin. */ + url = "/" + searchPath(app.key, q); + } else { + url = bases[app.key] + searchPath(app.key, q); + } + fetchSection(app.key, url, function (err, data) { + if (myRun !== runId) return; + if (err) { + var msg; + if (err.name === "AbortError") msg = "Délai dépassé (8 s)"; + else if (err.status === 401 || err.status === 403) + msg = "Connexion requise sur " + app.label; + else if (err.status) msg = "Indisponible (HTTP " + err.status + ")"; + else msg = "Injoignable depuis cet hôte (CORS ou réseau)"; + renderSection(sec, { + status: "error", + message: msg, + app: app.key, + base: bases[app.key], + q: q, + }); + return; + } + var items = normalize(app.key, data, bases[app.key], q); + renderSection(sec, { + status: items.length ? "ok" : "empty", + items: items, + app: app.key, + base: bases[app.key], + q: q, + }); + }); + }); + } + + function scheduleSearch(q, immediate) { + if (debounceTimer) clearTimeout(debounceTimer); + if (immediate) { + runSearch(q); + return; + } + debounceTimer = setTimeout(function () { + runSearch(q); + }, DEBOUNCE_MS); + } + + function syncUrl(q) { + try { + var url = new URL(global.location.href); + if (q) url.searchParams.set("q", q); + else url.searchParams.delete("q"); + global.history.replaceState(null, "", url.toString()); + } catch (e) {} + } + + input.addEventListener("input", function () { + var q = input.value.trim(); + syncUrl(q); + scheduleSearch(q, false); + }); + input.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + var first = document.querySelector("[data-rp-link]"); + if (first) { + e.preventDefault(); + first.click(); + } + } else if (e.key === "Escape") { + input.value = ""; + syncUrl(""); + scheduleSearch("", true); + input.focus(); + } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { + moveFocus(e.key === "ArrowDown" ? 1 : -1, e); + } + }); + document.addEventListener( + "keydown", + function (e) { + var target = e.target; + var typing = + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable); + if ((e.key === "/" && !typing) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k")) { + e.preventDefault(); + e.stopPropagation(); + input.focus(); + input.select(); + return; + } + if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) { + moveFocus(e.key === "ArrowDown" ? 1 : -1, e); + } + }, + true + ); + + function moveFocus(delta, e) { + var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]")); + if (!links.length) return; + var active = document.activeElement; + var idx = links.indexOf(active); + var next; + if (idx === -1) next = delta > 0 ? 0 : links.length - 1; + else next = (idx + delta + links.length) % links.length; + e.preventDefault(); + links[next].focus(); + } + + var params = new URLSearchParams(global.location.search); + var initialQ = (params.get("q") || "").trim(); + if (initialQ) { + input.value = initialQ; + runSearch(initialQ); + } else { + runSearch(""); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})( + typeof window !== "undefined" + ? window + : typeof globalThis !== "undefined" + ? globalThis + : this +); diff --git a/tests/recherche.test.js b/tests/recherche.test.js new file mode 100644 index 0000000..c1c3c02 --- /dev/null +++ b/tests/recherche.test.js @@ -0,0 +1,68 @@ +/* Tests Node sans navigateur de recherche.js (logique pure). + * Usage : node recherche.test.js — sortie 0 si tout passe. */ +"use strict"; +const assert = require("node:assert/strict"); +const rp = require("../static/js/recherche.js"); + +let n = 0; +function ok(cond, label) { + assert.ok(cond, label); + n += 1; +} + +/* esc */ +ok(rp.esc('&') === "<a href="x">&", "esc échappe HTML"); + +/* isDevHost : même règle que la barre v0.1.6 */ +ok(rp.isDevHost("tiktak-dev.dracodev.net") === true, "dev reconnu"); +ok(rp.isDevHost("tiktak.dracodev.net") === false, "prod non dev"); +ok(rp.isDevHost("x-dev.dracodev.net.evil") === false, "hôte piège rejeté"); +ok(rp.isDevHost("dev.dracodev.net") === false, "étiquette trop courte rejetée"); +ok(rp.isDevHost("TIKTAK-DEV.DRACODEV.NET") === true, "insensible à la casse"); + +/* resolveBases : ordre explicite > barre > dev > href > prod */ +let b = rp.resolveBases({ + host: "tiktak-dev.dracodev.net", + explicit: { obsigate: "https://og-perso.example/" }, + barUrls: { flowdeck: "https://flowdeck-barre.example/" }, + tabHrefs: { kanbante: "https://kanbante-href.example/" }, +}); +ok(b.obsigate === "https://og-perso.example/", "explicite prioritaire"); +ok(b.flowdeck === "https://flowdeck-barre.example/", "barre avant dev"); +ok(b.tiktak === "https://tiktak-dev.dracodev.net/", "dev pour l'hôte courant"); +ok(b.kanbante === "https://kanbante-dev.dracodev.net/", "dev avant href"); +b = rp.resolveBases({ host: "tiktak.dracodev.net", tabHrefs: { kanbante: "https://kanbante-href.example/" } }); +ok(b.kanbante === "https://kanbante-href.example/", "href en prod"); +ok(b.obsigate === "https://og.dracodev.net/", "défaut prod"); +b = rp.resolveBases({}); +ok(b.flowdeck === "https://flowdeck.dracodev.net/", "défaut prod sans hôte"); + +/* searchPath */ +ok(rp.searchPath("obsigate", "café & thé") === "api/search?q=caf%C3%A9%20%26%20th%C3%A9&vault=all&limit=8", "path ObsiGate"); +ok(rp.searchPath("tiktak", "a b") === "api/search?q=a%20b", "path TikTak"); + +/* normalize ObsiGate */ +let items = rp.normalize("obsigate", { results: [{ vault: "home-bruno", path: "Notes/Idée.md", title: "Idée", snippet: "extrait" }] }, "https://og-dev.dracodev.net/", "idée"); +ok(items.length === 1 && items[0].url === "https://og-dev.dracodev.net/#file=home-bruno:Notes%2FId%C3%A9e.md", "lien profond ObsiGate"); +ok(items[0].context === "home-bruno · Notes/Idée.md", "contexte ObsiGate"); + +/* normalize Flowdeck */ +items = rp.normalize("flowdeck", { pages: [{ title: "P", excerpt: "ex", subtitle: "Espace", url: "/pages/42" }], collections: [{ title: "B", subtitle: "Database · Espace", url: "/db/7" }] }, "https://flowdeck-dev.dracodev.net/", "p"); +ok(items.length === 2 && items[0].url === "https://flowdeck-dev.dracodev.net/pages/42", "lien page Flowdeck"); +ok(items[1].url === "https://flowdeck-dev.dracodev.net/db/7", "lien base Flowdeck"); + +/* normalize TikTak */ +items = rp.normalize("tiktak", { tasks: [{ id: 9, title: "Faire X", description: "d", status: "active", due_at: "2026-10-30 09:00:00" }] }, "https://tiktak-dev.dracodev.net/", "faire"); +ok(items[0].url === "https://tiktak-dev.dracodev.net/search?q=faire&task=9", "lien profond TikTak"); +ok(items[0].context === "Tâche · Active · échéance 2026-10-30", "contexte TikTak"); + +/* normalize Kanbante */ +items = rp.normalize("kanbante", { groups: [{ full_name: "Projets/site", rows: [{ issue: { number: 12, title: "Bug", body: "corps" }, column: "in_progress" }] }] }, "https://kanbante-dev.dracodev.net/", "bug"); +ok(items[0].title === "#12 Bug" && items[0].url === "https://kanbante-dev.dracodev.net/board/Projets/site", "lien tableau Kanbante"); +ok(items[0].context === "Projets/site · En cours", "contexte Kanbante"); + +/* nativeSearchUrl */ +ok(rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") === "https://tiktak-dev.dracodev.net/search?q=q", "repli TikTak"); +ok(rp.nativeSearchUrl("obsigate", "https://og-dev.dracodev.net/", "q") === "https://og-dev.dracodev.net/", "repli ObsiGate = accueil (palette)"); + +console.log("recherche.test.js : " + n + " assertions OK"); diff --git a/tests/test_recherche.py b/tests/test_recherche.py new file mode 100644 index 0000000..9b58017 --- /dev/null +++ b/tests/test_recherche.py @@ -0,0 +1,73 @@ +"""Tests de la recherche fédérée Flowdeck (fusion #68). + +Fonctions pures de app/middleware/recherche_cors.py uniquement : +aucune base, aucun serveur. +""" + +from app.middleware.recherche_cors import ( + cors_headers_for, + is_dev_host, + is_prod_host, + origin_of, + preflight_headers, + sister_origins, +) + +RESOLVED_DEV = { + "obsigate": "https://og-dev.dracodev.net/", + "flowdeck": "https://flowdeck-dev.dracodev.net", + "tiktak": "https://tiktak-dev.dracodev.net/", + "kanbante": "https://kanbante-dev.dracodev.net/", + "agent": "https://agent-dev.dracodev.net/", + "gitea": "https://git.dracodev.net/", +} + + +def test_is_dev_host(): + assert is_dev_host("flowdeck-dev.dracodev.net") is True + assert is_dev_host("flowdeck.dracodev.net") is False + assert is_dev_host("x-dev.dracodev.net.evil") is False + assert is_dev_host("flowdeck-dev.dracodev.net:8443") is True + assert is_prod_host("flowdeck.dracodev.net") is True + assert is_prod_host("flowdeck-dev.dracodev.net") is False + + +def test_origin_of(): + assert origin_of("https://og-dev.dracodev.net/") == "https://og-dev.dracodev.net" + assert origin_of("{{URL_OBSIGATE}}") is None + + +def test_sister_origins_dev(): + assert sister_origins(RESOLVED_DEV) == { + "https://og-dev.dracodev.net", + "https://flowdeck-dev.dracodev.net", + "https://tiktak-dev.dracodev.net", + "https://kanbante-dev.dracodev.net", + } + + +def test_sister_origins_ajoute_env_de_la_requete(): + # Configuration muette (hors requête) + hôte dev : les -dev passent. + assert "https://og-dev.dracodev.net" in sister_origins( + {}, request_host="flowdeck-dev.dracodev.net" + ) + assert "https://og.dracodev.net" in sister_origins( + {}, request_host="flowdeck.dracodev.net" + ) + assert "https://og-dev.dracodev.net" not in sister_origins( + {}, request_host="flowdeck.dracodev.net" + ) + + +def test_cors_headers(): + headers = cors_headers_for("https://og-dev.dracodev.net", RESOLVED_DEV) + assert headers["Access-Control-Allow-Origin"] == "https://og-dev.dracodev.net" + assert headers["Access-Control-Allow-Credentials"] == "true" + assert cors_headers_for("https://evil.example", RESOLVED_DEV) is None + assert cors_headers_for("https://og.dracodev.net", RESOLVED_DEV) is None + + +def test_preflight_headers(): + headers = preflight_headers("https://kanbante-dev.dracodev.net", RESOLVED_DEV) + assert headers["Access-Control-Allow-Methods"] == "GET, OPTIONS" + assert preflight_headers("https://evil.example", RESOLVED_DEV) is None