From ba0ec3d1faaf0fcf43e48f010873a92776aafa81 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 17 Sep 2026 13:59:23 -0400 Subject: [PATCH] feat(config): cles des sources connectees et recherche a cle editables depuis la page Configurations (#103) --- CHANGELOG.md | 20 +++- README.fr.md | 10 +- README.md | 10 +- VERSION | 2 +- backend/main.py | 62 ++++++++++++ backend/tools/connected.py | 12 ++- backend/tools/secrets.py | 109 ++++++++++++++++++++ backend/tools/web.py | 5 +- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 3 +- docs/features/ai-tools-roadmap.md | 2 +- frontend/index.html | 128 +++++++++++++++++++++++ frontend/js/config.js | 100 ++++++++++++++++++ frontend/locales/en.json | 8 ++ frontend/locales/fr.json | 8 ++ package.json | 2 +- tests/test_tool_keys.py | 163 ++++++++++++++++++++++++++++++ 19 files changed, 629 insertions(+), 21 deletions(-) create mode 100644 backend/tools/secrets.py create mode 100644 tests/test_tool_keys.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 729fb91..0d1c5de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.10.1**. +> [Unreleased](#unreleased). La dernière version livrée est **2.11.0**. --- @@ -14,6 +14,24 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.11.0] — 2026-09-17 + +### Ajouté + +- **#103 — Clés des sources connectées configurables depuis la page Configurations** : nouvelle + section « 🔗 Sources connectées & recherche » (admin) permettant de saisir ses propres jetons + sans toucher aux variables d'environnement : clés de recherche web à clé (Tavily, Brave, + SerpAPI, Exa), URL Gitea + token, token GitHub. Stockage dans `data/api_keys.json` (même + fichier que les clés des fournisseurs IA) via les endpoints + `GET/POST/DELETE /api/config/tool-keys` (GET masque les secrets, URLs en clair ; liste + blanche stricte ; admin requis). Les outils lisent la valeur stockée **en priorité** puis + l'environnement (Infisical) — `backend/tools/secrets.py`. Fichiers : + `backend/tools/{secrets,web,connected}.py`, `backend/main.py`, `frontend/index.html`, + `frontend/js/config.js`, `frontend/locales/{fr,en}.json`, `tests/test_tool_keys.py` (nouveau), + `tests/test_connected_tools.py`. + +--- + ## [2.10.1] — 2026-09-17 ### Corrigé diff --git a/README.fr.md b/README.fr.md index 8795ade..08a9b4b 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.10.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.11.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -289,6 +289,10 @@ Un compte **admin** connecté voit une icône 🛡️ dans le header : liste, cr | `OBSIGATE_GITEA_URL` / `OBSIGATE_GITEA_TOKEN` | Source connectée Gitea (outil `git_list_repos`…) | — | | `OBSIGATE_GITHUB_TOKEN` | Jeton GitHub (outil `git_list_repos`…) | — | +> Ces clés peuvent aussi être saisies **depuis l'interface** (menu → Configurations → +> « Sources connectées & recherche ») : la valeur saisie est stockée dans `data/api_keys.json` +> et prime sur la variable d'environnement. + ### Volume pour la persistance Les données d'auth (`users.json`, `secret.key`) sont stockées dans `/app/data` : @@ -922,8 +926,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.10.1). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.11.0). --- -*Projet : ObsiGate | Version : 2.10.1 | Dernière mise à jour : Juin 2026* +*Projet : ObsiGate | Version : 2.11.0 | Dernière mise à jour : Juin 2026* diff --git a/README.md b/README.md index 19e0cb9..52831e2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.10.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.11.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -327,6 +327,10 @@ When an **admin** account is logged in, a 🛡️ icon appears in the header. Cl | `OBSIGATE_GITEA_URL` / `OBSIGATE_GITEA_TOKEN` | Gitea connected source (`git_list_repos`…) | — | | `OBSIGATE_GITHUB_TOKEN` | GitHub token (`git_list_repos`…) | — | +> These keys can also be entered **from the UI** (menu → Configurations → +> "Connected sources & search"): the stored value goes to `data/api_keys.json` +> and takes precedence over the environment variable. + >All these variables are documented in `.env.example`. ### Volume for Persistence @@ -1091,8 +1095,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.10.1). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.11.0). --- -*Project: ObsiGate | Version: 2.10.1 | Last updated: May 2026* +*Project: ObsiGate | Version: 2.11.0 | Last updated: May 2026* diff --git a/VERSION b/VERSION index 8bbb6e4..46b81d8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.10.1 +2.11.0 diff --git a/backend/main.py b/backend/main.py index 3d96659..93c800f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3679,6 +3679,68 @@ async def api_delete_ai_key(provider_env: str, current_user=Depends(require_admi return {"status": "deleted", "key": key_name} +# --------------------------------------------------------------------------- +# Tool & connected-source keys (#103) — same store as the AI provider keys +# --------------------------------------------------------------------------- + +from backend.tools.secrets import ( + TOOL_KEY_NAMES as _TOOL_KEY_NAMES, +) +from backend.tools.secrets import ( + delete_tool_key as _delete_tool_key, +) +from backend.tools.secrets import ( + get_tool_key as _get_tool_key, +) +from backend.tools.secrets import ( + mask_value as _mask_tool_value, +) +from backend.tools.secrets import ( + set_tool_key as _set_tool_key, +) + + +@app.get("/api/config/tool-keys", response_model=AIKeysResponse) +async def api_get_tool_keys(current_user=Depends(require_admin)): + """Return tool/connected-source configuration (tokens masked, URLs clear).""" + masked = {} + for name in _TOOL_KEY_NAMES: + masked[name] = _mask_tool_value(name, _get_tool_key(name)) + return masked + + +@app.post("/api/config/tool-keys", response_model=StatusResponse) +async def api_set_tool_keys(body: dict = Body(...), current_user=Depends(require_admin)): + """Save tool/connected-source keys. + + Only whitelisted names (``backend.tools.secrets.TOOL_KEY_NAMES``) are + accepted: Tavily/Brave/SerpAPI/Exa API keys, Gitea URL + token, GitHub + token. Empty values delete the stored entry. + """ + updated = [] + for name, value in body.items(): + if name not in _TOOL_KEY_NAMES: + raise HTTPException(status_code=400, detail=f"Clé inconnue: {name}") + if value is not None and not isinstance(value, str): + raise HTTPException(status_code=400, detail=f"Type invalide pour {name}") + _set_tool_key(name, value or "") + updated.append(name) + logger.info(f"Tool keys updated: {updated}") + return {"status": "ok"} + + +@app.delete("/api/config/tool-keys/{name}", response_model=AIKeyDeleteResponse) +async def api_delete_tool_key(name: str, current_user=Depends(require_admin)): + """Delete a stored tool key (the environment fallback still applies).""" + key_name = name.upper() + try: + existed = _delete_tool_key(key_name) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + logger.info(f"Tool key deleted: {key_name} (existed={existed})") + return {"status": "deleted", "key": key_name} + + @app.post("/api/config/ai-keys/test", response_model=AITestResponse) async def api_test_ai_keys(current_user=Depends(require_admin)): """Test which AI providers are configured. diff --git a/backend/tools/connected.py b/backend/tools/connected.py index e52354b..ff3dcc1 100644 --- a/backend/tools/connected.py +++ b/backend/tools/connected.py @@ -11,7 +11,9 @@ hard-coded): * ``OBSIGATE_GITEA_URL`` — base URL of the self-hosted instance (e.g. ``https://git.example.net``); the ``gitea`` provider is only available when this variable is set. Admin-controlled, so the SSRF guard does not apply - (unlike user-supplied URLs). + (unlike user-supplied URLs). Both the URL and the tokens can also be set + from the configuration page (stored in ``data/api_keys.json``, #103) — + the stored value takes precedence over the environment. * ``OBSIGATE_GITEA_TOKEN`` — optional personal access token (private repos). * ``OBSIGATE_GITHUB_TOKEN`` — optional token (raises the API rate limits and unlocks private repositories). @@ -26,7 +28,6 @@ from __future__ import annotations import base64 import binascii import logging -import os from typing import Any import httpx @@ -34,6 +35,7 @@ import httpx from backend.tools.context import ToolError, ToolRisk from backend.tools.registry import tool from backend.tools.schemas import GitGetFileInput, GitProviderInput, GitSearchIssuesInput +from backend.tools.secrets import get_tool_key logger = logging.getLogger("obsigate.tools.connected") @@ -46,16 +48,16 @@ GITHUB_API = "https://api.github.com" def _provider_base(provider: str) -> tuple[str, str]: """Return (base_url, auth_header_value) for the requested provider.""" if provider == "gitea": - base = os.environ.get("OBSIGATE_GITEA_URL", "").strip().rstrip("/") + base = get_tool_key("OBSIGATE_GITEA_URL").rstrip("/") if not base: raise ToolError( "Source Gitea non configurée (OBSIGATE_GITEA_URL absente).", code="provider_not_configured", ) - token = os.environ.get("OBSIGATE_GITEA_TOKEN", "").strip() + token = get_tool_key("OBSIGATE_GITEA_TOKEN") return base, f"token {token}" if token else "" if provider == "github": - token = os.environ.get("OBSIGATE_GITHUB_TOKEN", "").strip() + token = get_tool_key("OBSIGATE_GITHUB_TOKEN") return GITHUB_API, f"Bearer {token}" if token else "" raise ToolError( f"Fournisseur inconnu : {provider} ('gitea' ou 'github')", diff --git a/backend/tools/secrets.py b/backend/tools/secrets.py new file mode 100644 index 0000000..a117a6b --- /dev/null +++ b/backend/tools/secrets.py @@ -0,0 +1,109 @@ +"""Tool-layer secrets — user-configured tokens & API keys (#103). + +The connected-source (Gitea / GitHub) and keyed web-search (Tavily, Brave, +SerpAPI, Exa) tools read their credentials through this module instead of +``os.environ`` directly. The value comes from the store the user edits in the +configuration page (``data/api_keys.json`` — the same file the AI provider +keys use) first, then falls back to the environment (Infisical-injected in +production). Nothing is ever hard-coded and no tool result carries a secret +(the registry redacts payloads). + +Allowed names are whitelisted: only the variables below can be stored or +deleted from the configuration page. +""" + +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path + +logger = logging.getLogger("obsigate.tools.secrets") + +# Whitelisted configuration names (config page « Sources connectées & recherche »). +TOOL_KEY_NAMES: tuple[str, ...] = ( + "OBSIGATE_TAVILY_API_KEY", + "OBSIGATE_BRAVE_API_KEY", + "OBSIGATE_SERPAPI_API_KEY", + "OBSIGATE_EXA_API_KEY", + "OBSIGATE_GITEA_URL", + "OBSIGATE_GITEA_TOKEN", + "OBSIGATE_GITHUB_TOKEN", +) + +_SECRET_MARKERS = ("API_KEY", "TOKEN") + + +def _keys_file() -> Path: + base = os.environ.get("OBSIGATE_DATA_DIR", "data") + return Path(base) / "api_keys.json" + + +def _read_keys() -> dict: + path = _keys_file() + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as e: + logger.warning("tool key store unreadable (%s): %s", path, e) + return {} + return data if isinstance(data, dict) else {} + + +def _write_keys(data: dict) -> None: + path = _keys_file() + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2), encoding="utf-8") + tmp.replace(path) + + +def is_secret_name(name: str) -> bool: + """True for API keys / tokens (masked in API responses); URLs are clear.""" + return any(marker in name for marker in _SECRET_MARKERS) + + +def mask_value(name: str, value: str) -> str: + """Mask a secret for display; non-secret values (URLs) are returned as-is.""" + if not value: + return "" + if not is_secret_name(name): + return value + return value[:4] + "..." + value[-4:] if len(value) > 8 else "***" + + +def get_tool_key(name: str) -> str: + """Stored (configuration page) value first, then environment fallback.""" + if name not in TOOL_KEY_NAMES: + return os.environ.get(name, "").strip() + stored = _read_keys().get(name) + if isinstance(stored, str) and stored.strip(): + return stored.strip() + return os.environ.get(name, "").strip() + + +def set_tool_key(name: str, value: str) -> None: + """Persist one whitelisted key into the store (admin configuration page).""" + if name not in TOOL_KEY_NAMES: + raise ValueError(f"Clé non prise en charge: {name}") + value = (value or "").strip() + keys = _read_keys() + if value: + keys[name] = value + else: + keys.pop(name, None) + _write_keys(keys) + + +def delete_tool_key(name: str) -> bool: + """Remove one key from the store; return True when it existed.""" + if name not in TOOL_KEY_NAMES: + raise ValueError(f"Clé non prise en charge: {name}") + keys = _read_keys() + if name in keys: + del keys[name] + _write_keys(keys) + return True + return False diff --git a/backend/tools/web.py b/backend/tools/web.py index b62d1d9..04f2cce 100644 --- a/backend/tools/web.py +++ b/backend/tools/web.py @@ -57,6 +57,7 @@ from backend.tools import webcache from backend.tools.context import ToolError, ToolRisk, ToolScope from backend.tools.registry import tool from backend.tools.schemas import FetchUrlInput, WebSearchInput +from backend.tools.secrets import get_tool_key logger = logging.getLogger("obsigate.tools.web") @@ -202,8 +203,8 @@ def _with_retry(call: Callable[[], Any]) -> Any: def _env_key(name: str) -> str: - """Read an API key from the environment (Infisical-injected in prod).""" - return os.environ.get(name, "").strip() + """Read an API key: configuration-page store first, then environment.""" + return get_tool_key(name) def _search_tavily(query: str, params: WebSearchInput) -> tuple[list[dict[str, Any]], list[str]]: diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 4a7d4e3..9df8455 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.10.1" +version = "2.11.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index ac83c21..15c709e 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.10.1" +version = "2.11.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 4225019..3a6b19d 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.10.1", + "version": "2.11.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 87b9e65..cd1746c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.10.1 | **Dernière mise à jour :** 2026-09-17 +> **Version :** 2.11.0 | **Dernière mise à jour :** 2026-09-17 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -185,6 +185,7 @@ | BUG-057 | Assistant IA — bouton « Ajouter » fonctionnel dans l'éditeur Forge (en plus d'« Editer ») | 2.9.0 | [archive](./archive/COMPLETED_v1-v2.md) | | 102 | Assistant IA — bouton « Ajouter la section » par bloc de code (insertion du bloc seul) | 2.9.0 | [archive](./archive/COMPLETED_v1-v2.md) | | 92 | Assistant IA — Écosystème d'outils phase 2 (recherche à clé, cache/retry, Playwright, crawl, Gitea/GitHub, documents XLSX/DOCX/CSV/PDF) | 2.10.0 | [features/ai-tools-roadmap.md](./features/ai-tools-roadmap.md) | +| 103 | Configuration — clés utilisateur des sources connectées & recherche à clé (page Configurations, `data/api_keys.json`, priorité sur l'env) | 2.11.0 | [features/ai-tools-roadmap.md](./features/ai-tools-roadmap.md) | --- diff --git a/docs/features/ai-tools-roadmap.md b/docs/features/ai-tools-roadmap.md index 71d89c1..798d191 100644 --- a/docs/features/ai-tools-roadmap.md +++ b/docs/features/ai-tools-roadmap.md @@ -43,7 +43,7 @@ réécriture de la boucle n'est nécessaire. | Recherche web étendue | Tavily, Brave, SerpAPI, Exa à clé (`OBSIGATE_*_API_KEY`), essayés avant SearXNG ; ordre via `OBSIGATE_WEB_PROVIDERS` | | Lecture de pages | `fetch_url(render=True)` → worker Playwright isolé (`backend/tools/webrender.py`), dépendance optionnelle + erreur explicite | | Crawl multi-pages | `crawl_site` (WRITE + confirmation) : BFS httpx borné (≤ 20 pages, même hôte, SSRF sur chaque URL) → condensé Markdown dans le vault. Scrapy écarté (dépendance lourde inutile à cette échelle) | -| Sources connectées | Gitea + GitHub (`git_list_repos`, `git_search_issues`, `git_get_file`) via env/Infisical ; drives cloud (Drive/OneDrive) orientés serveur MCP externe (#79) | +| Sources connectées | Gitea + GitHub (`git_list_repos`, `git_search_issues`, `git_get_file`) via env/Infisical ; drives cloud (Drive/OneDrive) orientés serveur MCP externe (#79). **#103 (2.11.0)** : les clés (URL Gitea, tokens Gitea/GitHub, clés Tavily/Brave/SerpAPI/Exa) se saisissent aussi dans la page Configurations — `backend/tools/secrets.py`, valeur stockée prioritaire sur l'env | | Production de documents | `create_xlsx`, `create_docx`, `create_csv`, `create_pdf` — WRITE + confirmation, écrit via `save_raw_file(allow_docs=True)` (path safety + backup) | | Transverse | Cache SQLite (`webcache.py`, TTL `OBSIGATE_WEB_CACHE_TTL`), retry backoff maison (`OBSIGATE_WEB_RETRY`), secrets par env (Infisical-compatible) | diff --git a/frontend/index.html b/frontend/index.html index e49b66a..21db46f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1543,6 +1543,7 @@
  • +
  • Sources connectées
  • @@ -2260,6 +2261,133 @@ + +
    +

    Sources connectées & recherche web

    +

    + Clés utilisées par les outils de l'Assistant IA + (recherche web à clé, Gitea, GitHub). Elles sont + stockées localement et priment sur les variables + d'environnement. +

    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    deleteToolKey(inputId)); + row.appendChild(delBtn); + } +} + +function _setToolKeyBadge(inputId, hasKey) { + const badge = document.getElementById(inputId + "-badge"); + const delBtn = document.getElementById(inputId + "-delete"); + if (badge) { + if (hasKey) { + badge.textContent = "\u2713 " + t("config.key_set"); + badge.style.background = "var(--success-bg, #27ae6022)"; + badge.style.color = "var(--success, #27ae60)"; + badge.style.border = "1px solid var(--success, #27ae60)"; + } else { + badge.textContent = t("config.key_unset"); + badge.style.background = "var(--muted-bg, #ffffff10)"; + badge.style.color = "var(--text-muted, #888)"; + badge.style.border = "1px solid var(--border, #444)"; + } + } + if (delBtn) delBtn.style.display = hasKey ? "inline-block" : "none"; +} + +async function loadToolKeys() { + _ensureToolKeyUI(); + try { + const data = await api("/api/config/tool-keys"); + for (const [inputId, name] of Object.entries(TOOL_KEY_MAP)) { + const input = document.getElementById(inputId); + const val = data[name] || ""; + if (input && !input.value.trim()) input.placeholder = val || input.placeholder; + _setToolKeyBadge(inputId, !!val); + } + } catch(e) { /* non-admin: the section stays inert */ } +} + +async function saveToolKeys() { + const keys = {}; + for (const [id, name] of Object.entries(TOOL_KEY_MAP)) { + const input = document.getElementById(id); + if (input && input.value.trim()) keys[name] = input.value.trim(); + } + if (!Object.keys(keys).length) { + showToast(t("config.no_keys"), "warning"); + return; + } + try { + await api("/api/config/tool-keys", { method: "POST", body: JSON.stringify(keys) }); + showToast(t("config.api_keys_saved"), "success"); + Object.keys(TOOL_KEY_MAP).forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; }); + loadToolKeys(); + } catch(e) { showToast("Erreur: " + e.message, "error"); } +} + +async function deleteToolKey(inputId) { + const name = TOOL_KEY_MAP[inputId]; + if (!name) return; + if (!confirm(t("config.delete_key_confirm") + " " + name + " ?")) return; + try { + await api("/api/config/tool-keys/" + name, { method: "DELETE" }); + showToast(t("config.key_deleted") + " " + name, "success"); + loadToolKeys(); + } catch(e) { showToast("Erreur: " + e.message, "error"); } +} + + export { initSidebarTabs, initConfigModal, diff --git a/frontend/locales/en.json b/frontend/locales/en.json index b10b539..9103940 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -364,6 +364,14 @@ "config.ai_model": "Model", "config.ai_openrouter_label": "OpenRouter API Key", "config.api_keys_saved": "API keys saved", + "config.section_sources": "🔗 Connected sources & search", + "config.sources_desc": "Keys used by the AI assistant tools (keyed web search: Tavily, Brave, SerpAPI, Exa; connected sources: Gitea, GitHub). They are stored server-side and take precedence over environment variables.", + "config.gitea_url": "Gitea URL", + "config.key_set": "Configured", + "config.key_unset": "Not configured", + "config.delete_key": "Delete", + "config.delete_key_confirm": "Delete key", + "config.key_deleted": "Key deleted:", "config.backups": "Backups", "config.backups_desc": "Manage automatic file backups.", "config.client_config": "Client config", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 8276761..aa37ede 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -364,6 +364,14 @@ "config.ai_model": "Modèle", "config.ai_openrouter_label": "OpenRouter API Key", "config.api_keys_saved": "Clés API sauvegardées", + "config.section_sources": "🔗 Sources connectées & recherche", + "config.sources_desc": "Clés utilisées par les outils de l'Assistant IA (recherche web à clé : Tavily, Brave, SerpAPI, Exa ; sources connectées : Gitea, GitHub). Elles sont stockées sur le serveur et priment sur les variables d'environnement.", + "config.gitea_url": "URL Gitea", + "config.key_set": "Configuré", + "config.key_unset": "Non configuré", + "config.delete_key": "Supprimer", + "config.delete_key_confirm": "Supprimer la clé", + "config.key_deleted": "Clé supprimée :", "config.backups": "Sauvegardes", "config.backups_desc": "Gérez les sauvegardes automatiques de vos fichiers.", "config.client_config": "Configuration client", diff --git a/package.json b/package.json index 478d5a8..6843c0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.10.1", + "version": "2.11.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/test_tool_keys.py b/tests/test_tool_keys.py new file mode 100644 index 0000000..4643f3c --- /dev/null +++ b/tests/test_tool_keys.py @@ -0,0 +1,163 @@ +"""Unit tests for the tool/connected-source key store (#103). + +Covers ``backend.tools.secrets`` (precedence, masking, whitelist) and the +``/api/config/tool-keys`` endpoints (masked GET, POST, DELETE, admin-only). +""" + +import json +from pathlib import Path + +import pytest + +from backend.tools.secrets import ( + TOOL_KEY_NAMES, + delete_tool_key, + get_tool_key, + is_secret_name, + mask_value, + set_tool_key, +) + + +@pytest.fixture +def key_store(tmp_path, monkeypatch): + """Isolated key store directory.""" + monkeypatch.setenv("OBSIGATE_DATA_DIR", str(tmp_path)) + yield tmp_path + + +def _write_store(tmp_path, data): + (tmp_path / "api_keys.json").write_text(json.dumps(data), encoding="utf-8") + + +class TestGetToolKey: + def test_stored_value_takes_precedence_over_env(self, key_store, monkeypatch): + _write_store(key_store, {"OBSIGATE_GITHUB_TOKEN": "stored-token"}) + monkeypatch.setenv("OBSIGATE_GITHUB_TOKEN", "env-token") + assert get_tool_key("OBSIGATE_GITHUB_TOKEN") == "stored-token" + + def test_env_fallback_when_not_stored(self, key_store, monkeypatch): + monkeypatch.setenv("OBSIGATE_GITEA_TOKEN", "env-token") + assert get_tool_key("OBSIGATE_GITEA_TOKEN") == "env-token" + + def test_missing_everywhere_returns_empty(self, key_store, monkeypatch): + monkeypatch.delenv("OBSIGATE_GITHUB_TOKEN", raising=False) + assert get_tool_key("OBSIGATE_GITHUB_TOKEN") == "" + + def test_non_whitelisted_name_uses_env_only(self, key_store, monkeypatch): + _write_store(key_store, {"OTHER_KEY": "stored"}) + monkeypatch.setenv("OTHER_KEY", "env") + assert get_tool_key("OTHER_KEY") == "env" + + def test_whitelist_covers_expected_names(self): + assert set(TOOL_KEY_NAMES) == { + "OBSIGATE_TAVILY_API_KEY", + "OBSIGATE_BRAVE_API_KEY", + "OBSIGATE_SERPAPI_API_KEY", + "OBSIGATE_EXA_API_KEY", + "OBSIGATE_GITEA_URL", + "OBSIGATE_GITEA_TOKEN", + "OBSIGATE_GITHUB_TOKEN", + } + + +class TestSetDelete: + def test_set_then_get_roundtrip(self, key_store): + set_tool_key("OBSIGATE_TAVILY_API_KEY", "tvly-1234") + assert get_tool_key("OBSIGATE_TAVILY_API_KEY") == "tvly-1234" + + def test_set_empty_value_deletes_entry(self, key_store): + set_tool_key("OBSIGATE_TAVILY_API_KEY", "tvly-1234") + set_tool_key("OBSIGATE_TAVILY_API_KEY", "") + assert get_tool_key("OBSIGATE_TAVILY_API_KEY") == "" + assert "OBSIGATE_TAVILY_API_KEY" not in json.loads( + (key_store / "api_keys.json").read_text(encoding="utf-8") + ) + + def test_delete_removes_and_reports(self, key_store): + set_tool_key("OBSIGATE_GITEA_URL", "https://git.example.net") + assert delete_tool_key("OBSIGATE_GITEA_URL") is True + assert delete_tool_key("OBSIGATE_GITEA_URL") is False + + def test_unknown_name_rejected(self, key_store): + with pytest.raises(ValueError): + set_tool_key("NOT_WHITELISTED", "x") + with pytest.raises(ValueError): + delete_tool_key("NOT_WHITELISTED") + + def test_store_keeps_other_entries(self, key_store): + _write_store(key_store, {"DEEPSEEK_API_KEY": "sk-existing"}) + set_tool_key("OBSIGATE_EXA_API_KEY", "exa-key") + data = json.loads((key_store / "api_keys.json").read_text(encoding="utf-8")) + assert data["DEEPSEEK_API_KEY"] == "sk-existing" + assert data["OBSIGATE_EXA_API_KEY"] == "exa-key" + + +class TestMasking: + def test_urls_returned_clear(self): + assert mask_value("OBSIGATE_GITEA_URL", "https://git.example.net") == \ + "https://git.example.net" + + def test_tokens_masked(self): + masked = mask_value("OBSIGATE_GITHUB_TOKEN", "ghp_abcdefgh1234") + assert masked.startswith("ghp_") + assert "abcdefgh1234" not in masked + assert "..." in masked + + def test_short_secret_fully_masked(self): + assert mask_value("OBSIGATE_EXA_API_KEY", "abc") == "***" + + def test_secret_detection(self): + assert is_secret_name("OBSIGATE_GITEA_TOKEN") + assert is_secret_name("OBSIGATE_TAVILY_API_KEY") + assert not is_secret_name("OBSIGATE_GITEA_URL") + + +class TestToolKeysAPI: + def _login(self, admin_client): + resp = admin_client.post( + "/api/auth/login", json={"username": "admin", "password": "chab30"} + ) + assert resp.status_code == 200, resp.text + token = resp.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + def test_get_masks_tokens_shows_urls(self, admin_client, key_store): + headers = self._login(admin_client) + _write_store(key_store, { + "OBSIGATE_GITEA_URL": "https://git.example.net", + "OBSIGATE_GITHUB_TOKEN": "ghp_abcdefgh1234", + }) + resp = admin_client.get("/api/config/tool-keys", headers=headers) + assert resp.status_code == 200 + data = resp.json() + assert data["OBSIGATE_GITEA_URL"] == "https://git.example.net" + assert "abcdefgh1234" not in data["OBSIGATE_GITHUB_TOKEN"] + assert data["OBSIGATE_GITHUB_TOKEN"].startswith("ghp_") + + def test_post_roundtrip_then_delete(self, admin_client, key_store): + headers = self._login(admin_client) + resp = admin_client.post( + "/api/config/tool-keys", + headers=headers, + json={"OBSIGATE_EXA_API_KEY": "exa-key-1234"}, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + assert get_tool_key("OBSIGATE_EXA_API_KEY") == "exa-key-1234" + + resp = admin_client.delete("/api/config/tool-keys/OBSIGATE_EXA_API_KEY", headers=headers) + assert resp.status_code == 200 + assert resp.json()["status"] == "deleted" + assert get_tool_key("OBSIGATE_EXA_API_KEY") == "" + + def test_post_unknown_name_rejected(self, admin_client, key_store): + headers = self._login(admin_client) + resp = admin_client.post( + "/api/config/tool-keys", headers=headers, json={"MY_SECRET": "x"} + ) + assert resp.status_code == 400 + + def test_requires_admin(self, admin_client, key_store): + resp = admin_client.get("/api/config/tool-keys", headers={}) + assert resp.status_code in (401, 403)