feat(config): cles des sources connectees et recherche a cle editables depuis la page Configurations (#103)
CI / lint (push) Successful in 1m46s
CI / security (push) Successful in 1m23s
CI / test (push) Successful in 3m42s
CI / build (push) Successful in 58s
CI / e2e (push) Successful in 10m50s

This commit is contained in:
2026-09-17 13:59:26 -04:00
parent 22e9240e4f
commit ba0ec3d1fa
19 changed files with 629 additions and 21 deletions
+19 -1
View File
@@ -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é
+7 -3
View File
@@ -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*
+7 -3
View File
@@ -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*
+1 -1
View File
@@ -1 +1 @@
2.10.1
2.11.0
+62
View File
@@ -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.
+7 -5
View File
@@ -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')",
+109
View File
@@ -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
+3 -2
View File
@@ -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]]:
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.10.1"
version = "2.11.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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",
+2 -1
View File
@@ -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) |
---
+1 -1
View File
@@ -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) |
+128
View File
@@ -1543,6 +1543,7 @@
<li><a href="#cfg-hidden-files" class="help-nav-link" data-i18n="config.section_hidden"></a></li>
<li><a href="#cfg-diags" class="help-nav-link" data-i18n="settings.diagnostics"></a></li>
<li><a href="#cfg-ai" class="help-nav-link" data-i18n="settings.ai"></a></li>
<li><a href="#cfg-sources" class="help-nav-link" data-i18n="config.section_sources">Sources connectées</a></li>
<li><a href="#cfg-themes" class="help-nav-link" data-i18n="settings.themes"></a></li>
<li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li>
<li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li>
@@ -2260,6 +2261,133 @@
</div>
</section>
<!-- Connected sources & keyed web search (#103) -->
<section
class="config-section help-section"
id="cfg-sources"
>
<h2 data-i18n="config.section_sources"
>Sources connectées & recherche web</h2
>
<p
class="config-description"
data-i18n="config.sources_desc"
>
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.
</p>
<div class="config-row">
<label
class="config-label"
for="cfg-tavily-key"
>Tavily API Key</label
>
<input
type="password"
id="cfg-tavily-key"
class="config-input"
placeholder="tvly-..."
autocomplete="off"
/>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-brave-key"
>Brave Search API Key</label
>
<input
type="password"
id="cfg-brave-key"
class="config-input"
placeholder="BSA..."
autocomplete="off"
/>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-serpapi-key"
>SerpAPI Key</label
>
<input
type="password"
id="cfg-serpapi-key"
class="config-input"
placeholder="..."
autocomplete="off"
/>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-exa-key"
>Exa API Key</label
>
<input
type="password"
id="cfg-exa-key"
class="config-input"
placeholder="..."
autocomplete="off"
/>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-gitea-url"
data-i18n="config.gitea_url"
>URL Gitea</label
>
<input
type="text"
id="cfg-gitea-url"
class="config-input"
placeholder="https://git.example.net"
autocomplete="off"
/>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-gitea-token"
>Gitea Token</label
>
<input
type="password"
id="cfg-gitea-token"
class="config-input"
placeholder="token personnel..."
autocomplete="off"
/>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-github-token"
>GitHub Token</label
>
<input
type="password"
id="cfg-github-token"
class="config-input"
placeholder="ghp_..."
autocomplete="off"
/>
</div>
<div
class="config-actions-row"
style="margin-top: 16px"
>
<button class="config-btn-save"
id="cfg-save-tool-keys" data-i18n="help.shortcut_save">
Sauvegarder
</button>
</div>
</section>
<!-- Themes -->
<section
class="config-section help-section"
+100
View File
@@ -715,6 +715,7 @@ function initConfigModal() {
await loadHiddenFilesSettings();
loadWebhooksUI();
loadSharesUI();
loadToolKeys();
safeCreateIcons();
});
@@ -765,6 +766,9 @@ function initConfigModal() {
if (saveAIKeysBtn) saveAIKeysBtn.addEventListener("click", saveAIKeys);
const testAIKeysBtn = document.getElementById("cfg-test-ai-keys");
if (testAIKeysBtn) testAIKeysBtn.addEventListener("click", testAIKeys);
// Tool & connected-source keys (#103)
const saveToolKeysBtn = document.getElementById("cfg-save-tool-keys");
if (saveToolKeysBtn) saveToolKeysBtn.addEventListener("click", saveToolKeys);
// Default provider/model selection
const aiDefaultProviderSel = document.getElementById("cfg-ai-default-provider");
if (aiDefaultProviderSel) {
@@ -1715,6 +1719,102 @@ async function testAIKeys() {
}
// ── Tool & connected-source keys (#103) ──
const TOOL_KEY_MAP = {
"cfg-tavily-key": "OBSIGATE_TAVILY_API_KEY",
"cfg-brave-key": "OBSIGATE_BRAVE_API_KEY",
"cfg-serpapi-key": "OBSIGATE_SERPAPI_API_KEY",
"cfg-exa-key": "OBSIGATE_EXA_API_KEY",
"cfg-gitea-url": "OBSIGATE_GITEA_URL",
"cfg-gitea-token": "OBSIGATE_GITEA_TOKEN",
"cfg-github-token": "OBSIGATE_GITHUB_TOKEN",
};
function _ensureToolKeyUI() {
for (const [inputId] of Object.entries(TOOL_KEY_MAP)) {
const input = document.getElementById(inputId);
if (!input) continue;
const row = input.closest(".config-row");
if (!row || row.dataset.toolKeyEnhanced) continue;
row.dataset.toolKeyEnhanced = "1";
row.style.cssText += "display:flex;align-items:center;gap:8px;flex-wrap:wrap;";
const badge = document.createElement("span");
badge.id = inputId + "-badge";
badge.style.cssText = "font-size:11px;padding:2px 8px;border-radius:10px;white-space:nowrap;";
row.appendChild(badge);
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.id = inputId + "-delete";
delBtn.className = "config-btn-secondary";
delBtn.style.cssText = "font-size:11px;padding:4px 10px;color:var(--danger,#e74c3c);border-color:var(--danger,#e74c3c);cursor:pointer;display:none;";
delBtn.textContent = "\u00d7 " + t("config.delete_key");
delBtn.addEventListener("click", () => 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,
+8
View File
@@ -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",
+8
View File
@@ -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",
+1 -1
View File
@@ -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": {
+163
View File
@@ -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)