From 4142e7c73ec3e3ce39083aaaf8bfd4599067ccce Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 12:44:28 +0000 Subject: [PATCH] =?UTF-8?q?Param=C3=A8tres=20:=20cl=C3=A9=20d'API=20FF=20N?= =?UTF-8?q?ancy=20(en-t=C3=AAte=20X-API-Key)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'API api.ffnancy.fr exige désormais une clé (401 « Clé d'API requise »). - Nouveau champ « Clé d'API » dans Paramètres → API FF Nancy, stocké dans data/.db-config.json (ffApiKey), jamais renvoyé au navigateur ; vide = conserver la clé enregistrée. Repli sur la variable FF_API_KEY. - Tous les appels à l'API FF envoient l'en-tête X-API-Key (ff_get, proxy commandes-auto, publicités, /api/diag, test de connexion). - Le test de connexion signale explicitement une clé manquante ou refusée. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KdY28Lq8Q8xX4RZsAvWRQ7 --- docker-compose.yml | 2 + server/src/admin/qlik_bridge.rs | 4 +- server/src/admin/settings.rs | 13 +++- server/src/config.rs | 3 + server/src/ff/api.rs | 82 +++++++++++++++++-------- server/src/ff/diagnostic.rs | 27 +++++++- server/src/ff/fixture_tests.rs | 4 +- server/src/ff/pages.rs | 16 +++-- server/src/ff/routes.rs | 10 +-- src/app/(dashboard)/settings/client.tsx | 45 ++++++++++++-- src/features/settings/actions.ts | 20 ++++-- 11 files changed, 167 insertions(+), 59 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 18b0e2c..7122b65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: - AUTH_SECRET=my-super-secret-key-that-should-be-changed-in-production - DATABASE_URL=postgres://postgres:MOT_DE_PASSE_ICI@postgresbase:5432/collectflow - FF_API_BASE_URL=https://api.ffnancy.fr + # Clé d'API FF Nancy (en-tête X-API-Key) — ou à saisir dans Paramètres. + # - FF_API_KEY=VOTRE_CLE_ICI # La base, Qlik et l'API FF se règlent aussi depuis l'interface (Paramètres). volumes: - collectflow-data:/app/data diff --git a/server/src/admin/qlik_bridge.rs b/server/src/admin/qlik_bridge.rs index 08d8e7b..9cfa096 100644 --- a/server/src/admin/qlik_bridge.rs +++ b/server/src/admin/qlik_bridge.rs @@ -22,8 +22,8 @@ pub fn reset_ff_api_base() { } /// Diagnostic de l'API FF Nancy. -pub async fn diagnostiquer_api_ff(base: Option) -> Value { - serde_json::to_value(crate::ff::diagnostic::diagnostiquer_api_ff(base).await).unwrap_or(Value::Null) +pub async fn diagnostiquer_api_ff(base: Option, cle: Option) -> Value { + serde_json::to_value(crate::ff::diagnostic::diagnostiquer_api_ff(base, cle).await).unwrap_or(Value::Null) } /// Extraction Qlik d'un fournisseur sur la fenêtre de la synchro (`QLIK_SYNC_MONTHS_BACK`), diff --git a/server/src/admin/settings.rs b/server/src/admin/settings.rs index cec07fe..f3b23bd 100644 --- a/server/src/admin/settings.rs +++ b/server/src/admin/settings.rs @@ -116,6 +116,9 @@ pub async fn get_parametres_affichables(ctx: Ctx) -> AppResult { "motDePasseEnregistre": config.qlik_password.as_deref().map(|p| !p.is_empty()).unwrap_or(false) || qlik_pwd_env, }, "ffApiBaseUrl": config.ff_api_base_url.clone().unwrap_or_default(), + // La clé ne quitte pas le serveur : seulement l'indication qu'il y en a une + // (réglage enregistré, puis variable d'environnement `FF_API_KEY`). + "ffApiKeyEnregistree": crate::ff::api::resoudre_ff_api_key(config.ff_api_key.clone()).is_some(), })) } @@ -219,12 +222,14 @@ pub async fn test_qlik_connection(ctx: Ctx) -> AppResult { } } -/// Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. +/// Enregistre l'URL de l'API FF Nancy (chaîne vide = revenir au défaut) et sa +/// clé d'API (vide ou absente = conserver celle qui est enregistrée). pub async fn save_ff_api_settings(ctx: Ctx) -> AppResult { if let Err(e) = ctx.admin().await { return Ok(refus(e)); } let brut: String = ctx.arg::>(0)?.unwrap_or_default(); + let cle: Option = ctx.arg::>(1)?.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); let cleaned = brut.trim().trim_end_matches('/').to_string(); let lower = cleaned.to_ascii_lowercase(); if !cleaned.is_empty() && !(lower.starts_with("http://") || lower.starts_with("https://")) { @@ -232,6 +237,9 @@ pub async fn save_ff_api_settings(ctx: Ctx) -> AppResult { } let mut patch = Map::new(); patch.insert("ffApiBaseUrl".into(), if cleaned.is_empty() { Value::Null } else { Value::String(cleaned.clone()) }); + if let Some(cle) = cle { + patch.insert("ffApiKey".into(), Value::String(cle)); + } match write_config(patch) { Ok(()) => { // Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde. @@ -252,7 +260,8 @@ pub async fn test_ff_api_connection(ctx: Ctx) -> AppResult { return Ok(j!({ "success": false, "url": "", "error": e.message })); } let base: Option = ctx.arg(0)?; - Ok(super::qlik_bridge::diagnostiquer_api_ff(base).await) + let cle: Option = ctx.arg::>(1)?; + Ok(super::qlik_bridge::diagnostiquer_api_ff(base, cle).await) } #[cfg(test)] diff --git a/server/src/config.rs b/server/src/config.rs index d386c94..91cd171 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -43,6 +43,9 @@ pub struct DbConfig { /// API REST FF Nancy (ex. https://api.ffnancy.fr). #[serde(default)] pub ff_api_base_url: Option, + /// Clé d'API FF Nancy, envoyée dans l'en-tête `X-API-Key`. + #[serde(default)] + pub ff_api_key: Option, } fn read_raw_sync() -> Map { diff --git a/server/src/ff/api.rs b/server/src/ff/api.rs index 99554bc..f68f0a9 100644 --- a/server/src/ff/api.rs +++ b/server/src/ff/api.rs @@ -42,9 +42,21 @@ pub fn ff_client() -> &'static reqwest::Client { }) } -/// `fetch(url, ffFetchInit())`. +/// `fetch(url, ffFetchInit())`, clé d'API comprise. pub async fn ff_get(url: &str) -> reqwest::Result { - ff_client().get(url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await + avec_cle_ff(ff_client().get(url).header(reqwest::header::CACHE_CONTROL, "no-store")).await.send().await +} + +/// En-tête d'authentification attendu par l'API FF Nancy. +pub const FF_API_KEY_HEADER: &str = "X-API-Key"; + +/// Ajoute la clé d'API enregistrée (en-tête `X-API-Key`) à une requête vers l'API FF. +/// Sans clé configurée, la requête part telle quelle. +pub async fn avec_cle_ff(req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + match get_ff_api_key().await { + Some(cle) => req.header(FF_API_KEY_HEADER, cle), + None => req, + } } /// Libellé HTTP d'un statut (équivalent de `res.statusText`). @@ -52,7 +64,43 @@ pub fn status_text(status: reqwest::StatusCode) -> &'static str { status.canonical_reason().unwrap_or("") } -static FF_API_BASE_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +/// Réglages de connexion à l'API FF résolus : adresse et clé. +#[derive(Clone)] +struct FfApiReglages { + base: String, + cle: Option, +} + +static FF_API_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + +/// Chaîne non vide après `trim`, sinon `None`. +fn non_vide(s: Option) -> Option { + s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) +} + +/// Clé d'API FF : réglage enregistré dans Paramètres → variable d'environnement +/// `FF_API_KEY` → aucune. +pub fn resoudre_ff_api_key(configured: Option) -> Option { + non_vide(configured).or_else(|| non_vide(std::env::var("FF_API_KEY").ok())) +} + +async fn get_ff_api_reglages() -> FfApiReglages { + if let Some((value, at)) = FF_API_CACHE.lock().as_ref() { + if at.elapsed() < FF_API_BASE_TTL { + return value.clone(); + } + } + // Fichier absent ou illisible : on retombe sur l'environnement. + let config = tokio::task::spawn_blocking(crate::config::read_config).await.unwrap_or_default(); + let base = non_vide(config.ff_api_base_url) + .or_else(|| non_vide(std::env::var("FF_API_BASE_URL").ok())) + .unwrap_or_else(|| FF_API_BASE_DEFAULT.to_string()) + .trim_end_matches('/') + .to_string(); + let value = FfApiReglages { base, cle: resoudre_ff_api_key(config.ff_api_key) }; + *FF_API_CACHE.lock() = Some((value.clone(), Instant::now())); + value +} /// URL de l'API FF Nancy, résolue **au moment de l'appel**. /// @@ -63,31 +111,17 @@ static FF_API_BASE_CACHE: LazyLock>> = LazyLock: /// /// Le résultat est mémorisé 30 s pour ne pas relire le fichier à chaque requête. pub async fn get_ff_api_base() -> String { - if let Some((value, at)) = FF_API_BASE_CACHE.lock().as_ref() { - if at.elapsed() < FF_API_BASE_TTL { - return value.clone(); - } - } - // Fichier absent ou illisible : on retombe sur l'environnement. - let configured = tokio::task::spawn_blocking(|| crate::config::read_config().ff_api_base_url) - .await - .ok() - .flatten() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - let env = std::env::var("FF_API_BASE_URL").ok().filter(|s| !s.is_empty()); - let value = configured - .or(env) - .unwrap_or_else(|| FF_API_BASE_DEFAULT.to_string()) - .trim_end_matches('/') - .to_string(); - *FF_API_BASE_CACHE.lock() = Some((value.clone(), Instant::now())); - value + get_ff_api_reglages().await.base +} + +/// Clé d'API FF Nancy (même cache que l'adresse). +pub async fn get_ff_api_key() -> Option { + get_ff_api_reglages().await.cle } /// Vide le cache — appelé après un enregistrement dans Paramètres. pub fn reset_ff_api_base_cache() { - *FF_API_BASE_CACHE.lock() = None; + *FF_API_CACHE.lock() = None; } // --------------------------------------------------------------------------- diff --git a/server/src/ff/diagnostic.rs b/server/src/ff/diagnostic.rs index ca42fc2..7595670 100644 --- a/server/src/ff/diagnostic.rs +++ b/server/src/ff/diagnostic.rs @@ -5,7 +5,7 @@ //! route `/api/ff-status` (tout utilisateur connecté). Chaque appelant fait son //! propre contrôle d'accès. -use super::api::{normalize_sync_status, status_text, FfSyncStatus, FF_API_BASE_DEFAULT}; +use super::api::{get_ff_api_key, normalize_sync_status, status_text, FfSyncStatus, FF_API_BASE_DEFAULT, FF_API_KEY_HEADER}; use super::js::truthy; use serde::Serialize; use serde_json::Value; @@ -77,7 +77,13 @@ fn message_complet(e: &dyn std::error::Error) -> String { /// L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir /// si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue /// donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP. -pub async fn diagnostiquer_api_ff(ff_api_base_url: Option) -> DiagnosticApiFf { +/// +/// `ff_api_key` : clé saisie à tester ; vide ou absente, on reprend la clé enregistrée. +pub async fn diagnostiquer_api_ff(ff_api_base_url: Option, ff_api_key: Option) -> DiagnosticApiFf { + let cle = match ff_api_key.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) { + Some(c) => Some(c), + None => get_ff_api_key().await, + }; let saisie = ff_api_base_url.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); let base = match saisie { Some(b) => b, @@ -97,9 +103,24 @@ pub async fn diagnostiquer_api_ff(ff_api_base_url: Option) -> Diagnostic let started = Instant::now(); let client = reqwest::Client::builder().timeout(Duration::from_millis(8000)).build().unwrap_or_default(); - match client.get(&url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await { + let mut req = client.get(&url).header(reqwest::header::CACHE_CONTROL, "no-store"); + if let Some(cle) = &cle { + req = req.header(FF_API_KEY_HEADER, cle); + } + match req.send().await { Ok(res) => { let ms = started.elapsed().as_millis() as u64; + // 401/403 : le serveur répond, c'est la clé qui manque ou qui est refusée. + if matches!(res.status().as_u16(), 401 | 403) { + return DiagnosticApiFf::echec( + &url, + if cle.is_some() { + format!("Clé d'API refusée (HTTP {}) — vérifiez la clé saisie.", res.status().as_u16()) + } else { + format!("Clé d'API requise (HTTP {}) — renseignez la clé d'API.", res.status().as_u16()) + }, + ); + } if !res.status().is_success() { let txt = status_text(res.status()); return DiagnosticApiFf::echec( diff --git a/server/src/ff/fixture_tests.rs b/server/src/ff/fixture_tests.rs index 9efe6a8..74f0947 100644 --- a/server/src/ff/fixture_tests.rs +++ b/server/src/ff/fixture_tests.rs @@ -247,8 +247,8 @@ async fn module_ff_sur_base_de_test() { montrer("get_mensuel_by_articles", api::get_mensuel_by_articles(&ffarts, d, f, 50).await); montrer("get_referentiel_by_articles", api::get_referentiel_by_articles(&ffarts, 50).await); montrer("get_commandes_by_fournisseur", api::get_commandes_by_fournisseur("F001").await); - montrer("diagnostiquer_api_ff", diagnostic::diagnostiquer_api_ff(None).await); - montrer("diagnostiquer_api_ff(injoignable)", diagnostic::diagnostiquer_api_ff(Some("http://127.0.0.1:9".into())).await); + montrer("diagnostiquer_api_ff", diagnostic::diagnostiquer_api_ff(None, None).await); + montrer("diagnostiquer_api_ff(injoignable)", diagnostic::diagnostiquer_api_ff(Some("http://127.0.0.1:9".into()), None).await); // ── cache.rs montrer("get_derniere_reception_cached", cache::get_derniere_reception_cached(&codes(&["F002", "F001"])).await.unwrap()); diff --git a/server/src/ff/pages.rs b/server/src/ff/pages.rs index a21ad5f..70c60df 100644 --- a/server/src/ff/pages.rs +++ b/server/src/ff/pages.rs @@ -3,7 +3,7 @@ //! commandes fournisseurs et publicités. use super::actions::lister_cadences; -use super::api::get_ff_api_base; +use super::api::{avec_cle_ff, get_ff_api_base}; use super::cache::get_fournisseurs_cached; use super::js::{get, js_number, js_number_to_string, message_erreur}; use super::pg::{ @@ -223,12 +223,11 @@ async fn fetch_publicites_api(statut: String, page: String) -> PublicitesResult } let base = get_ff_api_base().await; let res = async { - let res = client_publicites() + let req = client_publicites() .get(format!("{base}/api/publicites")) .query(¶ms) - .header(reqwest::header::CACHE_CONTROL, "no-store") - .send() - .await?; + .header(reqwest::header::CACHE_CONTROL, "no-store"); + let res = avec_cle_ff(req).await.send().await?; if !res.status().is_success() { return anyhow::Ok(PublicitesResult { data: json_value!([]), @@ -262,11 +261,10 @@ async fn fetch_publicites_api(statut: String, page: String) -> PublicitesResult async fn fetch_historique_api() -> HistoriqueResult { let base = get_ff_api_base().await; let res = async { - let res = client_publicites() + let req = client_publicites() .get(format!("{base}/api/publicites/historique")) - .header(reqwest::header::CACHE_CONTROL, "no-store") - .send() - .await?; + .header(reqwest::header::CACHE_CONTROL, "no-store"); + let res = avec_cle_ff(req).await.send().await?; if !res.status().is_success() { return anyhow::Ok(HistoriqueResult { data: json_value!([]), diff --git a/server/src/ff/routes.rs b/server/src/ff/routes.rs index 67e0e63..099b01f 100644 --- a/server/src/ff/routes.rs +++ b/server/src/ff/routes.rs @@ -1,7 +1,7 @@ //! Routes HTTP du module FF : `/api/ff-status`, `/api/diag`, //! `/api/diag/stock-fournisseur`, `/api/test-db`, `/api/commandes-auto`. -use super::api::{ff_client, get_ff_api_base}; +use super::api::{avec_cle_ff, ff_client, get_ff_api_base}; use super::diagnostic::diagnostiquer_api_ff; use super::js::{encode, js_string, message_erreur}; use super::pg::{pg_diag_fournisseur_derniere_entree, pg_get_stock_for_codeins}; @@ -31,7 +31,7 @@ fn string_erreur(e: &dyn std::fmt::Display) -> String { /// et *quelle adresse* a été appelée. L'ancienne version renvoyait un 503 sans /// détail, impossible à distinguer d'une simple erreur d'URL. pub async fn ff_status(_user: SessionUser) -> Response { - let res = diagnostiquer_api_ff(None).await; + let res = diagnostiquer_api_ff(None, None).await; if !res.success { return ( StatusCode::SERVICE_UNAVAILABLE, @@ -115,7 +115,7 @@ pub async fn commandes_auto(_user: SessionUser, Query(q): Query Value { let res = async { - let res = reqwest::Client::new().get(&url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await?; + let res = avec_cle_ff(reqwest::Client::new().get(&url).header(reqwest::header::CACHE_CONTROL, "no-store")).await.send().await?; let status = res.status().as_u16(); let text = res.text().await?; anyhow::Ok((status, text)) @@ -189,7 +189,7 @@ fn premiere_liste(data: &Value) -> Vec { } async fn get_json(url: &str) -> anyhow::Result { - let res = reqwest::Client::new().get(url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await?; + let res = avec_cle_ff(reqwest::Client::new().get(url).header(reqwest::header::CACHE_CONTROL, "no-store")).await.send().await?; Ok(res.json::().await?) } diff --git a/src/app/(dashboard)/settings/client.tsx b/src/app/(dashboard)/settings/client.tsx index 0cb70b7..5bde235 100644 --- a/src/app/(dashboard)/settings/client.tsx +++ b/src/app/(dashboard)/settings/client.tsx @@ -292,22 +292,28 @@ function SectionFfApi() { const [savedUrl, setSavedUrl] = useState(null); const [saving, setSaving] = useState(false); const [testedUrl, setTestedUrl] = useState(null); + // Toujours vide au chargement : la clé enregistrée ne quitte pas le serveur. + const [cle, setCle] = useState(""); + const [cleEnregistree, setCleEnregistree] = useState(false); + const [showCle, setShowCle] = useState(false); - // Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut). + // Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut) + // et l'indication qu'une clé est enregistrée. useEffect(() => { loadSavedConfig() .then((cfg) => { setSavedUrl(cfg.ffApiBaseUrl || null); if (cfg.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl); + setCleEnregistree(cfg.ffApiKeyEnregistree); }) .catch(() => { /* réglage optionnel ; l'échec de lecture est signalé par la page */ }); }, []); - const runTest = useCallback((candidate?: string) => { + const runTest = useCallback((candidate?: string, cleCandidate?: string) => { setLoading(true); setError(null); setStatus(null); - testFfApiConnection(candidate) + testFfApiConnection(candidate, cleCandidate) .then((res) => { setTestedUrl(res.url); if (res.success) setStatus(res.status as FfSyncStatus); @@ -322,13 +328,17 @@ function SectionFfApi() { const save = async () => { setSaving(true); try { - const res = await saveFfApiSettings(url); + const res = await saveFfApiSettings(url, cle || undefined); if (!res.success) { toast.erreur(res.error ?? "Enregistrement impossible."); return; } setSavedUrl(url.trim() || null); - toast.succes(url.trim() ? "Adresse de l'API FF enregistrée." : "L'adresse par défaut de l'API FF sera utilisée."); + if (cle) { + setCle(""); + setCleEnregistree(true); + } + toast.succes(url.trim() ? "Réglages de l'API FF enregistrés." : "Réglages de l'API FF enregistrés (adresse par défaut)."); runTest(url); } catch (e) { toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`); @@ -361,13 +371,36 @@ function SectionFfApi() { className="w-full font-mono" /> + +
+ setCle(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") void save(); }} + className="w-full pr-10 font-mono" autoComplete="new-password" /> + +
+
- diff --git a/src/features/settings/actions.ts b/src/features/settings/actions.ts index fb51b72..7ba29ec 100644 --- a/src/features/settings/actions.ts +++ b/src/features/settings/actions.ts @@ -32,6 +32,8 @@ export interface ParametresAffichables { } | null; qlik: { host: string; user: string; motDePasseEnregistre: boolean }; ffApiBaseUrl: string; + /** Une clé d'API FF est enregistrée (la clé elle-même ne quitte pas le serveur). */ + ffApiKeyEnregistree: boolean; } export interface ResultatAction { @@ -67,12 +69,18 @@ export async function testQlikConnection(qlikHost: string, qlikUser: string, qli return callAction("testQlikConnection", [qlikHost, qlikUser, qlikPassword]); } -/** Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. */ -export async function saveFfApiSettings(ffApiBaseUrl: string): Promise { - return callAction("saveFfApiSettings", [ffApiBaseUrl]); +/** + * Enregistre l'URL de l'API FF Nancy (chaîne vide = revenir au défaut) et sa clé + * d'API (vide ou absente : celle qui est enregistrée est conservée). + */ +export async function saveFfApiSettings(ffApiBaseUrl: string, ffApiKey?: string): Promise { + return callAction("saveFfApiSettings", [ffApiBaseUrl, ffApiKey]); } -/** Teste l'API FF Nancy (diagnostic détaillé : DNS, refus, délai, code HTTP). */ -export async function testFfApiConnection(ffApiBaseUrl?: string): Promise { - return callAction("testFfApiConnection", [ffApiBaseUrl]); +/** + * Teste l'API FF Nancy (diagnostic détaillé : DNS, refus, délai, code HTTP, clé). + * Clé vide ou absente : on teste avec celle qui est enregistrée. + */ +export async function testFfApiConnection(ffApiBaseUrl?: string, ffApiKey?: string): Promise { + return callAction("testFfApiConnection", [ffApiBaseUrl, ffApiKey]); }