From ec20573de03898b8fa6b615e5d27832c03ce1da1 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:17:25 -0400 Subject: [PATCH 1/8] Recherche partout (#68) : JS --- frontend/js/recherche.js | 514 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 frontend/js/recherche.js diff --git a/frontend/js/recherche.js b/frontend/js/recherche.js new file mode 100644 index 0000000..3f396bd --- /dev/null +++ b/frontend/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 +); -- 2.54.0 From e0b39c6570d33e3cb91608cb367409f6c8cc7b8f Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:17:29 -0400 Subject: [PATCH 2/8] Recherche partout (#68) : CSS --- frontend/recherche.css | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 frontend/recherche.css diff --git a/frontend/recherche.css b/frontend/recherche.css new file mode 100644 index 0000000..f8ab9d9 --- /dev/null +++ b/frontend/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; } -- 2.54.0 From d134ad49f104dff127326a8d1cbfdfa58496d38f Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:17:33 -0400 Subject: [PATCH 3/8] Recherche partout (#68) : gabarit --- frontend/recherche.html | 55 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 frontend/recherche.html diff --git a/frontend/recherche.html b/frontend/recherche.html new file mode 100644 index 0000000..1a7a6fc --- /dev/null +++ b/frontend/recherche.html @@ -0,0 +1,55 @@ + + + + + + Rechercher partout — ObsiGate + + + + + + +
+

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.

+
+
+ + + -- 2.54.0 From 1d6ea4728f26f52929f705a6f97c239b3d84881b Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:17:38 -0400 Subject: [PATCH 4/8] Recherche partout (#68) : CORS --- backend/recherche_cors.py | 189 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 backend/recherche_cors.py diff --git a/backend/recherche_cors.py b/backend/recherche_cors.py new file mode 100644 index 0000000..af90390 --- /dev/null +++ b/backend/recherche_cors.py @@ -0,0 +1,189 @@ +"""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 de ``backend.draco_config`` + (``urls_publiques()`` : explicite > dérivation par l'hôte de + l'agent > défaut officiel), **plus** les origines prod ou ``-dev`` + officielles correspondant à l'hôte de la requête — ce second + ensemble couvre le cas où la configuration seule ne déclare pas + l'environnement courant ; +- avec credentials (le cookie SSO ``draco_session``, accepté par + ``/api/search`` via le repli de ``backend.auth.middleware``, ou + le cookie natif ``access_token``). + +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 same-origin explicite du CORSMiddleware +global (``allow_origins=[]``), qui reste la règle par défaut. +""" + +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_config.urls_publiques() 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 frontend/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 ``urls_publiques()``. + 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 backend.draco_config import urls_publiques + + resolved = urls_publiques() + 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) -- 2.54.0 From 67a486313f7f744fe6243a0144b924cce39bfad3 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:17:43 -0400 Subject: [PATCH 5/8] Recherche partout (#68) : routes+middleware --- backend/main.py | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/backend/main.py b/backend/main.py index bb8f117..5d67ec0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -521,6 +521,16 @@ 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 @@ -940,6 +950,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.""" @@ -947,3 +1022,4 @@ if FRONTEND_DIR.exists(): if index_file.exists(): return HTMLResponse(content=_index_html(request), headers={"Cache-Control": "no-cache"}) raise HTTPException(status_code=404, detail="Frontend not found") + -- 2.54.0 From 92e636477f7a9f49fe6a750431f32cce2ab0dcb2 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:17:49 -0400 Subject: [PATCH 6/8] Recherche partout (#68) : tests-py --- tests/test_recherche_cors.py | 69 ++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_recherche_cors.py diff --git a/tests/test_recherche_cors.py b/tests/test_recherche_cors.py new file mode 100644 index 0000000..86c7f3f --- /dev/null +++ b/tests/test_recherche_cors.py @@ -0,0 +1,69 @@ +"""Tests de la recherche fédérée ObsiGate (fusion #68). + +Fonctions pures de backend/recherche_cors.py uniquement : aucune base, +aucun serveur. +""" + +from backend.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("og-dev.dracodev.net") is True + assert is_dev_host("og.dracodev.net") is False + assert is_dev_host("x-dev.dracodev.net.evil") is False + assert is_prod_host("og.dracodev.net") is True + assert is_prod_host("og-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("https://og-dev.dracodev.net/chemin") == "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(): + assert "https://og-dev.dracodev.net" in sister_origins( + {}, request_host="og-dev.dracodev.net" + ) + assert "https://og-dev.dracodev.net" not in sister_origins( + {}, request_host="og.dracodev.net" + ) + + +def test_cors_headers(): + headers = cors_headers_for("https://tiktak-dev.dracodev.net", RESOLVED_DEV) + assert headers["Access-Control-Allow-Origin"] == "https://tiktak-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://flowdeck-dev.dracodev.net", RESOLVED_DEV) + assert headers["Access-Control-Allow-Methods"] == "GET, OPTIONS" + assert preflight_headers("https://evil.example", RESOLVED_DEV) is None -- 2.54.0 From 3a4ee20d74a6054c403af22e3741f30c927280ea Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:17:55 -0400 Subject: [PATCH 7/8] Recherche partout (#68) : tests-js --- tests/frontend/recherche.test.mjs | 62 +++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/frontend/recherche.test.mjs diff --git a/tests/frontend/recherche.test.mjs b/tests/frontend/recherche.test.mjs new file mode 100644 index 0000000..2204084 --- /dev/null +++ b/tests/frontend/recherche.test.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * ObsiGate — tests de la logique pure de la page « Rechercher partout » + * (fusion #68), sans DOM ni jsdom : résolution des bases, liens + * profonds et normalisation des 4 formats d'API. + * + * Le fichier frontend/js/recherche.js est chargé en ESM (frontend/js + * est "type": "module") ; ses helpers sont exposés via + * globalThis.RecherchePartout. Usage : node tests/frontend/recherche.test.mjs + */ +import { strict as assert } from "node:assert"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import path from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const JS_FILE = path.resolve(__dirname, "..", "..", "frontend", "js", "recherche.js"); + +await import(pathToFileURL(JS_FILE).href); +const rp = globalThis.RecherchePartout; +assert.ok(rp, "RecherchePartout exposé"); + +let n = 0; +const ok = (cond, label) => { + assert.ok(cond, label); + n += 1; +}; + +ok(rp.isDevHost("og-dev.dracodev.net") === true, "dev reconnu"); +ok(rp.isDevHost("x-dev.dracodev.net.evil") === false, "hôte piège rejeté"); + +const b = rp.resolveBases({ + host: "og-dev.dracodev.net", + explicit: { tiktak: "https://tiktak-perso.example/" }, + barUrls: {}, + tabHrefs: {}, +}); +ok(b.tiktak === "https://tiktak-perso.example/", "explicite prioritaire"); +ok(b.obsigate === "https://og-dev.dracodev.net/", "dev pour l'hôte courant"); +ok(b.kanbante === "https://kanbante-dev.dracodev.net/", "dev avant href"); +ok( + rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8", + "path ObsiGate", +); + +const 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[0].url === "https://og-dev.dracodev.net/#file=home-bruno:Notes%2FId%C3%A9e.md", + "lien profond ObsiGate", +); +ok( + rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") === + "https://tiktak-dev.dracodev.net/search?q=q", + "repli TikTak", +); + +console.log(`recherche.test.mjs : ${n} assertions OK`); -- 2.54.0 From 75139fbf6bb018bfec0b0d94c8c020d58cbe0293 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:18:02 -0400 Subject: [PATCH 8/8] Recherche partout (#68) : palette de la barre -> /recherche --- frontend/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/index.html b/frontend/index.html index f5b5988..42291f3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -431,7 +431,7 @@ if (b) b.click(); return; } - if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; } + if (act === "palette") { location.href = "/recherche"; return; } if (act === "menu") { var m = document.getElementById("header-menu-btn"); if (m) m.click(); return; } if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; } if (act === "spark") { -- 2.54.0