Compare commits

..
Author SHA1 Message Date
LogiFlow 61d45f5857 Merge pull request #32 from R0m1k3/claude/lucid-franklin-fsd5n7
Paramètres : clé d'API FF Nancy (en-tête X-API-Key)
2026-10-17 15:28:26 +02:00
Claude 4142e7c73e Paramètres : clé d'API FF Nancy (en-tête X-API-Key)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdY28Lq8Q8xX4RZsAvWRQ7
2026-10-17 12:44:28 +00:00
LogiFlow 712174cd85 Merge pull request #31 from R0m1k3/claude/cool-albattani-5zalq5
Qlik : test de bout en bout de l'extraction contre un faux Qlik
2026-10-15 08:33:23 +02:00
Claude 6cba754e06 Merge remote-tracking branch 'origin/main' into claude/cool-albattani-5zalq5
# Conflicts:
#	server/src/qlik/browser.rs
2026-10-14 16:57:08 +00:00
Claude 02d9a776f5 Qlik : extraction réseau coupée à 30 s par chromiumoxide, corrigée
Depuis le passage au serveur Rust, la mise à jour réseau d'un fournisseur
(ex. Sovacom) échouait sur « Request timed out » : chromiumoxide coupe toute
commande de page au bout de 30 s, en dur (REQUEST_TIMEOUT de CommandFuture),
sans tenir compte du request_timeout du navigateur. Or toute l'extraction
Qlik tient dans un seul Runtime.evaluate de plusieurs minutes. Playwright,
côté TS, n'avait pas cette limite.

PageQlik::evaluer lance désormais le script sans l'attendre, puis relève son
résultat par des évaluations courtes (20 ms → 1 s). Aucune commande CDP
n'approche plus les 30 s, quelle que soit la durée de l'extraction ; la
recherche produit, qui passe par la même fonction, en profite aussi.

Tests :
- tests/faux-qlik : faux Qlik Sense (HTTPS, NTLM → ticket → cookie, page
  avec jeton CSRF, mini-moteur Engine) ;
- extraction_faux_qlik (ignoré par défaut) : extraction complète de bout en
  bout avec un vrai Chromium, y compris contre celui de l'image de prod ;
- fenetre_identique_au_ts : fenêtre 12 mois identique à l'ancien TS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTseDg4hw2m9tGUJsqPt57
2026-10-14 15:58:40 +00:00
LogiFlow beff75c4c7 Merge pull request #30 from R0m1k3/claude/project-thread-m5x8ia
Qlik : extraction coupée à 30 s par chromiumoxide (« Request timed out »)
2026-10-14 16:10:12 +02:00
18 changed files with 609 additions and 59 deletions

No files matched your search

+2
View File
@@ -15,6 +15,8 @@ services:
- AUTH_SECRET=my-super-secret-key-that-should-be-changed-in-production - AUTH_SECRET=my-super-secret-key-that-should-be-changed-in-production
- DATABASE_URL=postgres://postgres:MOT_DE_PASSE_ICI@postgresbase:5432/collectflow - DATABASE_URL=postgres://postgres:MOT_DE_PASSE_ICI@postgresbase:5432/collectflow
- FF_API_BASE_URL=https://api.ffnancy.fr - 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). # La base, Qlik et l'API FF se règlent aussi depuis l'interface (Paramètres).
volumes: volumes:
- collectflow-data:/app/data - collectflow-data:/app/data
+2 -2
View File
@@ -22,8 +22,8 @@ pub fn reset_ff_api_base() {
} }
/// Diagnostic de l'API FF Nancy. /// Diagnostic de l'API FF Nancy.
pub async fn diagnostiquer_api_ff(base: Option<String>) -> Value { pub async fn diagnostiquer_api_ff(base: Option<String>, cle: Option<String>) -> Value {
serde_json::to_value(crate::ff::diagnostic::diagnostiquer_api_ff(base).await).unwrap_or(Value::Null) 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`), /// Extraction Qlik d'un fournisseur sur la fenêtre de la synchro (`QLIK_SYNC_MONTHS_BACK`),
+11 -2
View File
@@ -116,6 +116,9 @@ pub async fn get_parametres_affichables(ctx: Ctx) -> AppResult<Value> {
"motDePasseEnregistre": config.qlik_password.as_deref().map(|p| !p.is_empty()).unwrap_or(false) || qlik_pwd_env, "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(), "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<Value> {
} }
} }
/// 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<Value> { pub async fn save_ff_api_settings(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.admin().await { if let Err(e) = ctx.admin().await {
return Ok(refus(e)); return Ok(refus(e));
} }
let brut: String = ctx.arg::<Option<String>>(0)?.unwrap_or_default(); let brut: String = ctx.arg::<Option<String>>(0)?.unwrap_or_default();
let cle: Option<String> = ctx.arg::<Option<String>>(1)?.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let cleaned = brut.trim().trim_end_matches('/').to_string(); let cleaned = brut.trim().trim_end_matches('/').to_string();
let lower = cleaned.to_ascii_lowercase(); let lower = cleaned.to_ascii_lowercase();
if !cleaned.is_empty() && !(lower.starts_with("http://") || lower.starts_with("https://")) { 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<Value> {
} }
let mut patch = Map::new(); let mut patch = Map::new();
patch.insert("ffApiBaseUrl".into(), if cleaned.is_empty() { Value::Null } else { Value::String(cleaned.clone()) }); 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) { match write_config(patch) {
Ok(()) => { Ok(()) => {
// Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde. // 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<Value> {
return Ok(j!({ "success": false, "url": "", "error": e.message })); return Ok(j!({ "success": false, "url": "", "error": e.message }));
} }
let base: Option<String> = ctx.arg(0)?; let base: Option<String> = ctx.arg(0)?;
Ok(super::qlik_bridge::diagnostiquer_api_ff(base).await) let cle: Option<String> = ctx.arg::<Option<String>>(1)?;
Ok(super::qlik_bridge::diagnostiquer_api_ff(base, cle).await)
} }
#[cfg(test)] #[cfg(test)]
+3
View File
@@ -43,6 +43,9 @@ pub struct DbConfig {
/// API REST FF Nancy (ex. https://api.ffnancy.fr). /// API REST FF Nancy (ex. https://api.ffnancy.fr).
#[serde(default)] #[serde(default)]
pub ff_api_base_url: Option<String>, pub ff_api_base_url: Option<String>,
/// Clé d'API FF Nancy, envoyée dans l'en-tête `X-API-Key`.
#[serde(default)]
pub ff_api_key: Option<String>,
} }
fn read_raw_sync() -> Map<String, Value> { fn read_raw_sync() -> Map<String, Value> {
+58 -24
View File
@@ -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<reqwest::Response> { pub async fn ff_get(url: &str) -> reqwest::Result<reqwest::Response> {
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`). /// 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("") status.canonical_reason().unwrap_or("")
} }
static FF_API_BASE_CACHE: LazyLock<Mutex<Option<(String, Instant)>>> = 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<String>,
}
static FF_API_CACHE: LazyLock<Mutex<Option<(FfApiReglages, Instant)>>> = LazyLock::new(|| Mutex::new(None));
/// Chaîne non vide après `trim`, sinon `None`.
fn non_vide(s: Option<String>) -> Option<String> {
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<String>) -> Option<String> {
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**. /// URL de l'API FF Nancy, résolue **au moment de l'appel**.
/// ///
@@ -63,31 +111,17 @@ static FF_API_BASE_CACHE: LazyLock<Mutex<Option<(String, Instant)>>> = LazyLock:
/// ///
/// Le résultat est mémorisé 30 s pour ne pas relire le fichier à chaque requête. /// 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 { pub async fn get_ff_api_base() -> String {
if let Some((value, at)) = FF_API_BASE_CACHE.lock().as_ref() { get_ff_api_reglages().await.base
if at.elapsed() < FF_API_BASE_TTL { }
return value.clone();
} /// Clé d'API FF Nancy (même cache que l'adresse).
} pub async fn get_ff_api_key() -> Option<String> {
// Fichier absent ou illisible : on retombe sur l'environnement. get_ff_api_reglages().await.cle
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
} }
/// Vide le cache — appelé après un enregistrement dans Paramètres. /// Vide le cache — appelé après un enregistrement dans Paramètres.
pub fn reset_ff_api_base_cache() { pub fn reset_ff_api_base_cache() {
*FF_API_BASE_CACHE.lock() = None; *FF_API_CACHE.lock() = None;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+24 -3
View File
@@ -5,7 +5,7 @@
//! route `/api/ff-status` (tout utilisateur connecté). Chaque appelant fait son //! route `/api/ff-status` (tout utilisateur connecté). Chaque appelant fait son
//! propre contrôle d'accès. //! 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 super::js::truthy;
use serde::Serialize; use serde::Serialize;
use serde_json::Value; 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 /// 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 /// 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. /// 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<String>) -> 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<String>, ff_api_key: Option<String>) -> 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 saisie = ff_api_base_url.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let base = match saisie { let base = match saisie {
Some(b) => b, Some(b) => b,
@@ -97,9 +103,24 @@ pub async fn diagnostiquer_api_ff(ff_api_base_url: Option<String>) -> Diagnostic
let started = Instant::now(); let started = Instant::now();
let client = reqwest::Client::builder().timeout(Duration::from_millis(8000)).build().unwrap_or_default(); 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) => { Ok(res) => {
let ms = started.elapsed().as_millis() as u64; 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() { if !res.status().is_success() {
let txt = status_text(res.status()); let txt = status_text(res.status());
return DiagnosticApiFf::echec( return DiagnosticApiFf::echec(
+2 -2
View File
@@ -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_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_referentiel_by_articles", api::get_referentiel_by_articles(&ffarts, 50).await);
montrer("get_commandes_by_fournisseur", api::get_commandes_by_fournisseur("F001").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", diagnostic::diagnostiquer_api_ff(None, None).await);
montrer("diagnostiquer_api_ff(injoignable)", diagnostic::diagnostiquer_api_ff(Some("http://127.0.0.1:9".into())).await); montrer("diagnostiquer_api_ff(injoignable)", diagnostic::diagnostiquer_api_ff(Some("http://127.0.0.1:9".into()), None).await);
// ── cache.rs // ── cache.rs
montrer("get_derniere_reception_cached", cache::get_derniere_reception_cached(&codes(&["F002", "F001"])).await.unwrap()); montrer("get_derniere_reception_cached", cache::get_derniere_reception_cached(&codes(&["F002", "F001"])).await.unwrap());
+7 -9
View File
@@ -3,7 +3,7 @@
//! commandes fournisseurs et publicités. //! commandes fournisseurs et publicités.
use super::actions::lister_cadences; 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::cache::get_fournisseurs_cached;
use super::js::{get, js_number, js_number_to_string, message_erreur}; use super::js::{get, js_number, js_number_to_string, message_erreur};
use super::pg::{ 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 base = get_ff_api_base().await;
let res = async { let res = async {
let res = client_publicites() let req = client_publicites()
.get(format!("{base}/api/publicites")) .get(format!("{base}/api/publicites"))
.query(&params) .query(&params)
.header(reqwest::header::CACHE_CONTROL, "no-store") .header(reqwest::header::CACHE_CONTROL, "no-store");
.send() let res = avec_cle_ff(req).await.send().await?;
.await?;
if !res.status().is_success() { if !res.status().is_success() {
return anyhow::Ok(PublicitesResult { return anyhow::Ok(PublicitesResult {
data: json_value!([]), data: json_value!([]),
@@ -262,11 +261,10 @@ async fn fetch_publicites_api(statut: String, page: String) -> PublicitesResult
async fn fetch_historique_api() -> HistoriqueResult { async fn fetch_historique_api() -> HistoriqueResult {
let base = get_ff_api_base().await; let base = get_ff_api_base().await;
let res = async { let res = async {
let res = client_publicites() let req = client_publicites()
.get(format!("{base}/api/publicites/historique")) .get(format!("{base}/api/publicites/historique"))
.header(reqwest::header::CACHE_CONTROL, "no-store") .header(reqwest::header::CACHE_CONTROL, "no-store");
.send() let res = avec_cle_ff(req).await.send().await?;
.await?;
if !res.status().is_success() { if !res.status().is_success() {
return anyhow::Ok(HistoriqueResult { return anyhow::Ok(HistoriqueResult {
data: json_value!([]), data: json_value!([]),
+5 -5
View File
@@ -1,7 +1,7 @@
//! Routes HTTP du module FF : `/api/ff-status`, `/api/diag`, //! Routes HTTP du module FF : `/api/ff-status`, `/api/diag`,
//! `/api/diag/stock-fournisseur`, `/api/test-db`, `/api/commandes-auto`. //! `/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::diagnostic::diagnostiquer_api_ff;
use super::js::{encode, js_string, message_erreur}; use super::js::{encode, js_string, message_erreur};
use super::pg::{pg_diag_fournisseur_derniere_entree, pg_get_stock_for_codeins}; 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 /// et *quelle adresse* a été appelée. L'ancienne version renvoyait un 503 sans
/// détail, impossible à distinguer d'une simple erreur d'URL. /// détail, impossible à distinguer d'une simple erreur d'URL.
pub async fn ff_status(_user: SessionUser) -> Response { 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 { if !res.success {
return ( return (
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
@@ -115,7 +115,7 @@ pub async fn commandes_auto(_user: SessionUser, Query(q): Query<HashMap<String,
} }
let base = get_ff_api_base().await; let base = get_ff_api_base().await;
let url = format!("{base}/api/commandes-auto"); let url = format!("{base}/api/commandes-auto");
let res = ff_client().get(&url).query(&params).send().await?; let res = avec_cle_ff(ff_client().get(&url).query(&params)).await.send().await?;
if !res.status().is_success() { if !res.status().is_success() {
let status = res.status(); let status = res.status();
return anyhow::Ok( return anyhow::Ok(
@@ -142,7 +142,7 @@ pub async fn commandes_auto(_user: SessionUser, Query(q): Query<HashMap<String,
/// Résumé d'une sonde : statut HTTP et aperçu du contenu. /// Résumé d'une sonde : statut HTTP et aperçu du contenu.
async fn probe(url: String) -> Value { async fn probe(url: String) -> Value {
let res = async { 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 status = res.status().as_u16();
let text = res.text().await?; let text = res.text().await?;
anyhow::Ok((status, text)) anyhow::Ok((status, text))
@@ -189,7 +189,7 @@ fn premiere_liste(data: &Value) -> Vec<Value> {
} }
async fn get_json(url: &str) -> anyhow::Result<Value> { async fn get_json(url: &str) -> anyhow::Result<Value> {
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::<Value>().await?) Ok(res.json::<Value>().await?)
} }
+15
View File
@@ -116,6 +116,21 @@ pub async fn get_qlik_browser() -> anyhow::Result<Arc<Browser>> {
Ok(b) Ok(b)
} }
/// Tests : pilote un Chromium déjà lancé (`--remote-debugging-port`), par
/// exemple celui de l'image Docker de production, au lieu d'en lancer un.
#[cfg(test)]
pub async fn utiliser_chromium_distant(url: &str) -> anyhow::Result<()> {
let (browser, mut handler) = Browser::connect(url).await.context("[qlik] connexion au Chromium distant")?;
let connecte = Arc::new(AtomicBool::new(true));
let drapeau = connecte.clone();
tokio::spawn(async move {
while handler.next().await.is_some() {}
drapeau.store(false, Ordering::SeqCst);
});
*NAVIGATEUR.lock().await = Some(Navigateur { browser: Arc::new(browser), connecte });
Ok(())
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// File unique des extractions // File unique des extractions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+20
View File
@@ -298,6 +298,26 @@ mod tests {
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap() NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
} }
/// Valeurs de référence calculées par l'ancien `qlik-date-range.ts`
/// (`buildGridNetworkQlikDateFilter` + `getMonthRanges`).
#[test]
fn fenetre_identique_au_ts() {
for (jour, label, q_start, q_end, premier_mois) in [
("2026-10-10", "2025-10_2026-09", 45931, 46295, (45931, 45961)),
("2026-10-14", "2025-10_2026-09", 45931, 46295, (45931, 45961)),
("2026-01-05", "2025-01_2025-12", 45658, 46022, (45658, 45688)),
("2026-03-31", "2025-03_2026-02", 45717, 46081, (45717, 45747)),
] {
let f = build_grid_network_qlik_date_filter(d(jour), 12);
assert_eq!((f.label.as_str(), f.q_start, f.q_end, f.daily_serials.len()), (label, q_start, q_end, 365), "{jour}");
let mois = get_month_ranges(&f);
assert_eq!(mois.len(), 12, "{jour}");
assert_eq!((mois[0].q_start, mois[0].q_end), premier_mois, "{jour}");
assert_eq!(mois[11].q_end, q_end, "{jour}");
assert_eq!(mois.iter().map(|m| m.daily_serials.len()).sum::<usize>(), 365, "{jour}");
}
}
// [1] + [2] Repères Qlik et aller-retour. // [1] + [2] Repères Qlik et aller-retour.
#[test] #[test]
fn reperes_qlik() { fn reperes_qlik() {
+40
View File
@@ -173,3 +173,43 @@ async fn file_qlik_sequentielle() {
let attendu: Vec<String> = (0..4).flat_map(|i| [format!("début {i}"), format!("fin {i}")]).collect(); let attendu: Vec<String> = (0..4).flat_map(|i| [format!("début {i}"), format!("fin {i}")]).collect();
assert_eq!(o, attendu); assert_eq!(o, attendu);
} }
/// Extraction complète (NTLM → ticket → cookie → page → jeton CSRF → websocket
/// Engine → 12 mois → contrôles) contre le faux Qlik de `tests/faux-qlik` :
///
/// ```text
/// tests/faux-qlik/lancer.sh NB_CODES=2000 DELAI_MS=40000 # extraction de plusieurs minutes
/// NB_CODES=2000 cargo test extraction_faux_qlik -- --ignored --nocapture
/// ```
///
/// `FOURNISSEUR_TEST` (défaut `SOVA`, connu du faux Qlik) : un autre code force
/// la sélection par codes articles. `CHROMIUM_DISTANT=http://127.0.0.1:9333` :
/// pilote un Chromium déjà lancé, par exemple celui de l'image de production.
#[tokio::test]
#[ignore]
async fn extraction_faux_qlik() {
let _ = tracing_subscriber::fmt().with_env_filter("info").with_test_writer().try_init();
if std::env::var("PLAYWRIGHT_CHROMIUM_PATH").is_err() {
std::env::set_var("PLAYWRIGHT_CHROMIUM_PATH", "/opt/pw-browsers/chromium-1194/chrome-linux/chrome");
}
if let Ok(url) = std::env::var("CHROMIUM_DISTANT") {
super::browser::utiliser_chromium_distant(&url).await.expect("Chromium distant");
}
let nb: usize = std::env::var("NB_CODES").ok().and_then(|v| v.parse().ok()).unwrap_or(400);
let codes: Vec<String> = (0..nb).map(|i| (10000100000u64 + i as u64).to_string()).collect();
let mut cfg = super::client::get_qlik_config();
cfg.host = "localhost".into();
cfg.user = "FFSCH".into();
cfg.password = "Mot2Passe!".into();
let df = super::date_range::build_grid_network_qlik_date_filter_now(12);
let t0 = std::time::Instant::now();
let r = super::playwright::fetch_network_metrics_playwright(Some(codes.clone()), Some(cfg), Some(df.clone()), Some(std::env::var("FOURNISSEUR_TEST").unwrap_or_else(|_| "SOVA".into()))).await;
let m = r.expect("extraction");
eprintln!("EXTRACTION OK : {} métriques en {:?}, fenêtre {}", m.len(), t0.elapsed(), df.label);
let premier = m.values().next().unwrap();
eprintln!("exemple {} : {:?}", premier.code_centrale, premier.qte_by_month);
assert!(m.len() > nb * 9 / 10);
for v in m.values() {
assert_eq!(v.qte_by_month.as_ref().unwrap().len(), 12, "{}", v.code_centrale);
}
}
+5
View File
@@ -0,0 +1,5 @@
node_modules/
package-lock.json
*.pem
pid
serveur.log
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# Lance le faux Qlik (HTTPS :443, il faut donc les droits root) en arrière-plan.
#
# server/tests/faux-qlik/lancer.sh [NB_CODES=… NB_AUTRES=… DELAI_MS=… MOIS_MANQUANT=YYYY-MM]
#
# puis, depuis server/ :
# NB_CODES=… cargo test extraction_faux_qlik -- --ignored --nocapture
set -euo pipefail
D=$(cd "$(dirname "$0")" && pwd)
[ -d "$D/node_modules/ws" ] || npm install --prefix "$D" --no-audit --no-fund --silent
[ -f "$D/cert.pem" ] || openssl req -x509 -newkey rsa:2048 -nodes -keyout "$D/key.pem" -out "$D/cert.pem" -days 30 -subj "/CN=localhost" 2>/dev/null
[ -f "$D/pid" ] && kill "$(cat "$D/pid")" 2>/dev/null || true
sleep 0.5
env "$@" nohup node --max-old-space-size=4096 "$D/serveur.js" > "$D/serveur.log" 2>&1 &
echo $! > "$D/pid"
for _ in $(seq 1 60); do grep -q "écoute" "$D/serveur.log" && break; sleep 0.5; done
cat "$D/serveur.log"
+8
View File
@@ -0,0 +1,8 @@
{
"name": "collectflow-faux-qlik",
"private": true,
"description": "Faux serveur Qlik Sense pour le test de bout en bout de l'extraction réseau",
"dependencies": {
"ws": "8.18.0"
}
}
+337
View File
@@ -0,0 +1,337 @@
// Faux serveur Qlik Sense pour tester l'extraction CollectFlow de bout en bout
// (test `extraction_faux_qlik` de `src/qlik/tests_navigateur.rs`, lancement : `lancer.sh`).
//
// - HTTPS (certificat auto-signé) sur :443 ;
// - flux d'authentification : /hub/ → targetId → NTLM (type 1 → 2 → 3) → ticket → cookie ;
// - /sense/app/<id> : page qui exige le cookie et ouvre un websocket avec un jeton CSRF ;
// - /app/<id> (wss) : mini-moteur Engine (champs, sélections, hypercubes, list objects).
//
// Variables : NB_CODES (codes du fournisseur testé), NB_AUTRES, FOURNISSEUR,
// DELAI_MS (latence par GetLayout), MOIS_MANQUANT (YYYY-MM sans ventes).
"use strict";
const https = require("https");
const fs = require("fs");
const path = require("path");
const { WebSocketServer } = require("ws");
const ICI = __dirname;
const APP = process.env.APP || "9872ee6e-d64a-4b43-984a-076bf1f7f647";
const FOURNISSEUR = process.env.FOURNISSEUR || "SOVA";
const NB_CODES = Number(process.env.NB_CODES || 400);
const NB_AUTRES = Number(process.env.NB_AUTRES || 600);
const DELAI_MS = Number(process.env.DELAI_MS || 0);
const MOIS_MANQUANT = process.env.MOIS_MANQUANT || "";
const SESSION = "s3ss10n-" + Math.random().toString(36).slice(2);
const JETON = "Jt" + Math.random().toString(36).slice(2) + "_x-Y";
const TYPE2 = "TlRMTVNTUAACAAAADgAOADAAAAAVgoniASNFZ4mrze8AAAAAAAAAACQAJAA+AAAARABPAE0AQQBJAE4ARQACAA4ARABPAE0AQQBJAE4AAQAMAFMARQBSAFYARQBSAAAAAAA=";
const stats = { ntlm1: 0, ntlm3: 0, ticket: 0, page: 0, pageRefusee: 0, ws: 0, wsRefuse: 0, rpc: 0, methodes: {} };
const log = (...a) => console.log("[faux-qlik]", ...a);
// ─── Données ────────────────────────────────────────────────────────────────
const serial = (y, m, d) => Math.round((Date.UTC(y, m - 1, d) - Date.UTC(1899, 11, 30)) / 86400000);
const codesFou = Array.from({ length: NB_CODES }, (_, i) => String(10000100000 + i));
const codesAutres = Array.from({ length: NB_AUTRES }, (_, i) => String(10000900000 + i));
// Pseudo-aléa déterministe.
let graine = 42;
const alea = () => { graine = (graine * 1103515245 + 12345) % 2147483648; return graine / 2147483648; };
const faits = [];
const aujourdHui = new Date();
for (let y = 2024; y <= aujourdHui.getFullYear(); y++) {
for (let m = 1; m <= 12; m++) {
if (y === aujourdHui.getFullYear() && m > aujourdHui.getMonth() + 1) break;
const mois = `${y}${String(m).padStart(2, "0")}`;
if (MOIS_MANQUANT && MOIS_MANQUANT.replace("-", "") === mois) continue;
for (const [codes, fou] of [[codesFou, FOURNISSEUR], [codesAutres, "AUTRE"]]) {
for (const code of codes) {
// 2 magasins par mois, 1 fait chacun (jour variable dans le mois).
for (let k = 0; k < 2; k++) {
if (alea() < 0.15) continue;
const jour = 1 + Math.floor(alea() * 28);
const quantite = 1 + Math.floor(alea() * 20);
const ca = Math.round(quantite * (2 + alea() * 10) * 100) / 100;
faits.push({
"Article Code": code, code_fournisseur: fou, "Magasin Code": "M" + Math.floor(alea() * 270),
Date: serial(y, m, jour), "Année": y, Mois: mois,
quantite, ca_ht: ca, marge: Math.round(ca * 0.35 * 100) / 100,
});
}
}
}
}
}
log(`${faits.length} faits, ${codesFou.length} codes ${FOURNISSEUR}, ${codesAutres.length} autres`);
const CHAMPS_NUM = new Set(["Date", "Année"]);
const CHAMPS = ["Article Code", "code_fournisseur", "Magasin Code", "Date", "Année", "Mois", "Période"];
// Valeurs distinctes de chaque champ (ordre de chargement), pour les qElemNumber.
const valeursChamp = {};
for (const c of CHAMPS) valeursChamp[c] = [];
const indexValeur = {};
for (const c of CHAMPS) indexValeur[c] = new Map();
const ajouterValeur = (c, v) => { if (!indexValeur[c].has(v)) { indexValeur[c].set(v, valeursChamp[c].length); valeursChamp[c].push(v); } };
for (const f of faits) for (const c of CHAMPS) if (c in f) ajouterValeur(c, f[c]);
// « Période » : champ déconnecté des faits (comme un pont de calendrier sans effet ici).
for (const p of ["Année à date", "Mois à date", "12 mois glissants"]) ajouterValeur("Période", p);
// ─── Mesures ────────────────────────────────────────────────────────────────
const somme = (rows, k) => rows.reduce((t, r) => t + r[k], 0);
const distinct = (rows, k) => new Set(rows.map((r) => r[k])).size;
const EXPRESSIONS = {
"Sum(quantite)": (r) => somme(r, "quantite"),
"Sum(ca_ht)": (r) => somme(r, "ca_ht"),
"Sum(marge)": (r) => somme(r, "marge"),
"Count(DISTINCT [Magasin Code])": (r) => distinct(r, "Magasin Code"),
"0": () => 0,
};
const MAITRES = [
{ id: "JhqJ", titre: "CA N", expr: "Sum(ca_ht)", f: EXPRESSIONS["Sum(ca_ht)"] },
{ id: "41516861-5997-4635-8187-3643a2bde422", titre: "Quantité N", expr: "Sum(quantite)", f: EXPRESSIONS["Sum(quantite)"] },
{ id: "yNBLjc", titre: "Magasin Ventes Nb N", expr: "Count(DISTINCT [Magasin Code])", f: EXPRESSIONS["Count(DISTINCT [Magasin Code])"] },
{ id: "16778c00-a4e0-41c7-ab1c-179810d78172", titre: "CA par Magasin N", expr: "Sum(ca_ht)/Count(DISTINCT [Magasin Code])", f: (r) => { const n = distinct(r, "Magasin Code"); return n ? somme(r, "ca_ht") / n : 0; } },
{ id: "ea1e8feb-49ba-4eb3-8db6-72dcfb4c1222", titre: "Marge % N", expr: "Sum(marge)/Sum(ca_ht)", f: (r) => { const c = somme(r, "ca_ht"); return c ? somme(r, "marge") / c : 0; } },
{ id: "96862880-76cd-4957-bf25-b96901f2ac5f", titre: "Quantité COMP", expr: "0", f: () => 0 },
];
const DIMENSIONS = { yesCP: "Article Code", pfGAwTs: "Mois" };
const mesure = (m) => {
if (m.qLibraryId) {
const x = MAITRES.find((y) => y.id === m.qLibraryId);
if (!x) throw { code: 2, message: "Invalid measure library id " + m.qLibraryId };
return x.f;
}
const e = String(m.qDef?.qDef ?? "").trim();
const f = EXPRESSIONS[e];
if (!f) return () => NaN;
return f;
};
const dimension = (d) => {
if (d.qLibraryId) {
const c = DIMENSIONS[d.qLibraryId];
if (!c) throw { code: 2, message: "Invalid dimension library id " + d.qLibraryId };
return c;
}
return d.qDef?.qFieldDefs?.[0];
};
// ─── Session Engine ─────────────────────────────────────────────────────────
function nouvelleSession(ws) {
const selections = {}; // champ → Set de valeurs
const handles = new Map(); // handle → { type, ... }
const parId = new Map();
let prochainHandle = 2;
const filtre = () => {
const actifs = Object.entries(selections).filter(([c, s]) => s.size > 0 && c !== "Période");
return actifs.length === 0 ? faits : faits.filter((f) => actifs.every(([c, s]) => s.has(f[c])));
};
const nouveauHandle = (obj) => { const h = prochainHandle++; handles.set(h, obj); return h; };
const calculerCube = (def) => {
const dims = (def.qDimensions ?? []).map(dimension);
const mes = (def.qMeasures ?? []).map(mesure);
const rows = filtre();
const groupes = new Map();
if (dims.length === 0) groupes.set("", rows);
else for (const r of rows) {
const k = dims.map((d) => r[d]).join("\u0001");
let g = groupes.get(k);
if (!g) { g = []; groupes.set(k, g); }
g.push(r);
}
const lignes = [];
for (const g of groupes.values()) {
const cellules = dims.map((d) => ({ qText: String(g[0][d]), qNum: CHAMPS_NUM.has(d) ? g[0][d] : "NaN", qElemNumber: indexValeur[d].get(g[0][d]) ?? 0, qState: "O" }));
for (const f of mes) {
const v = f(g);
cellules.push({ qText: Number.isFinite(v) ? String(v) : "-", qNum: Number.isFinite(v) ? v : "NaN", qElemNumber: 0, qState: "L" });
}
lignes.push(cellules);
}
return { lignes, largeur: dims.length + mes.length };
};
const page = (lignes, p) => ({
qArea: { qTop: p.qTop, qLeft: p.qLeft ?? 0, qWidth: p.qWidth, qHeight: Math.min(p.qHeight, Math.max(0, lignes.length - p.qTop)) },
qMatrix: lignes.slice(p.qTop, p.qTop + p.qHeight).map((l) => l.slice(p.qLeft ?? 0, (p.qLeft ?? 0) + p.qWidth)),
});
const listeValeurs = (champ) => valeursChamp[champ].map((v, i) => [{ qText: String(v), qNum: CHAMPS_NUM.has(champ) ? v : "NaN", qElemNumber: i, qState: "O" }]);
const selectionner = (champ, valeurs, bascule) => {
const connues = valeursChamp[champ];
const trouvees = [];
for (const fv of valeurs) {
let v;
if (CHAMPS_NUM.has(champ) && typeof fv.qNum === "number") v = connues.find((x) => x === fv.qNum);
else v = connues.find((x) => String(x) === String(fv.qText));
if (v !== undefined) trouvees.push(v);
}
if (!bascule || !selections[champ]) selections[champ] = new Set();
for (const v of trouvees) {
if (bascule && selections[champ].has(v)) selections[champ].delete(v);
else selections[champ].add(v);
}
return trouvees.length > 0;
};
const methodes = {
OpenDoc: (h, p) => {
if (p.qDocName !== APP) throw { code: 1002, message: "App not found" };
return { qReturn: { qType: "Doc", qHandle: 1, qGenericId: APP } };
},
GetField: (h, p) => {
if (!CHAMPS.includes(p.qFieldName)) throw { code: 2, message: "Field not found " + p.qFieldName };
return { qReturn: { qType: "Field", qHandle: nouveauHandle({ type: "champ", champ: p.qFieldName }) } };
},
Clear: (h) => {
const o = handles.get(h);
if (o?.type !== "champ") throw { code: -32601, parameter: "Clear", message: "Method not found" };
delete selections[o.champ];
return { qReturn: true };
},
SelectValues: (h, p) => {
const o = handles.get(h);
if (o?.type !== "champ") throw { code: -32601, parameter: "SelectValues", message: "Method not found" };
return { qReturn: selectionner(o.champ, p.qFieldValues ?? [], !!p.qToggleMode) };
},
CreateSessionObject: (h, p) => {
const prop = p.qProp ?? {};
const id = "obj-" + prochainHandle;
const obj = { type: "objet", id, prop };
const handle = nouveauHandle(obj);
parId.set(id, handle);
return { qReturn: { qType: "GenericObject", qHandle: handle, qGenericType: prop.qInfo?.qType, qGenericId: id } };
},
DestroySessionObject: (h, p) => {
const handle = parId.get(p.qId);
if (handle == null) throw { code: 15, message: "Request aborted" };
parId.delete(p.qId);
handles.delete(handle);
return { qSuccess: true };
},
GetLayout: (h) => {
const o = handles.get(h);
if (o?.type !== "objet") throw { code: -32601, parameter: "GetLayout", message: "Method not found" };
const prop = o.prop;
if (prop.qMeasureListDef) {
return { qLayout: { qInfo: prop.qInfo, qMeasureList: { qItems: MAITRES.map((m) => ({ qInfo: { qId: m.id, qType: "measure" }, qMeta: { title: m.titre }, qData: { title: m.titre, expr: m.expr } })) } } };
}
if (prop.qDimensionListDef) {
return { qLayout: { qInfo: prop.qInfo, qDimensionList: { qItems: Object.entries(DIMENSIONS).map(([id, t]) => ({ qInfo: { qId: id, qType: "dimension" }, qMeta: { title: t }, qData: { title: t } })) } } };
}
if (prop.qListObjectDef) {
const champ = dimension(prop.qListObjectDef);
const lignes = listeValeurs(champ);
o.lignes = lignes;
o.champ = champ;
const pages = (prop.qListObjectDef.qInitialDataFetch ?? []).map((p) => page(lignes, p));
return { qLayout: { qInfo: prop.qInfo, qListObject: { qSize: { qcx: 1, qcy: lignes.length }, qDataPages: pages } } };
}
if (prop.qHyperCubeDef) {
const { lignes, largeur } = calculerCube(prop.qHyperCubeDef);
o.lignes = lignes;
if (largeur * Math.max(...(prop.qHyperCubeDef.qInitialDataFetch ?? [{ qHeight: 0 }]).map((p) => p.qHeight)) > 10000) {
throw { code: 7009, message: "Too many cells requested" };
}
const pages = (prop.qHyperCubeDef.qInitialDataFetch ?? []).map((p) => page(lignes, p));
return { qLayout: { qInfo: prop.qInfo, qHyperCube: { qSize: { qcx: largeur, qcy: lignes.length }, qDataPages: pages } } };
}
throw { code: 2, message: "Unsupported object" };
},
GetHyperCubeData: (h, p) => {
const o = handles.get(h);
if (!o?.lignes) throw { code: -32601, parameter: "GetHyperCubeData", message: "Method not found" };
for (const pg of p.qPages) if (pg.qWidth * pg.qHeight > 10000) throw { code: 7009, message: "Too many cells requested" };
return { qDataPages: p.qPages.map((pg) => page(o.lignes, pg)) };
},
GetListObjectData: (h, p) => {
const o = handles.get(h);
if (!o?.lignes) throw { code: -32601, parameter: "GetListObjectData", message: "Method not found" };
return { qDataPages: p.qPages.map((pg) => page(o.lignes, pg)) };
},
SelectListObjectValues: (h, p) => {
const o = handles.get(h);
if (!o?.champ) throw { code: -32601, parameter: "SelectListObjectValues", message: "Method not found" };
const vals = (p.qValues ?? []).map((i) => valeursChamp[o.champ][i]).filter((v) => v !== undefined);
if (!p.qToggleMode || !selections[o.champ]) selections[o.champ] = new Set();
for (const v of vals) selections[o.champ].add(v);
return { qReturn: vals.length > 0 };
},
};
ws.send(JSON.stringify({ jsonrpc: "2.0", method: "OnConnected", params: { qSessionState: "SESSION_CREATED" } }));
ws.on("message", async (raw) => {
let m;
try { m = JSON.parse(String(raw)); } catch { return; }
stats.rpc++;
stats.methodes[m.method] = (stats.methodes[m.method] ?? 0) + 1;
const f = methodes[m.method];
if (DELAI_MS && m.method === "GetLayout") await new Promise((r) => setTimeout(r, DELAI_MS));
try {
if (!f) throw { code: -32601, parameter: m.method, message: "Method not found" };
const result = f(m.handle, m.params ?? {});
ws.send(JSON.stringify({ jsonrpc: "2.0", id: m.id, result }));
} catch (e) {
const error = e && typeof e === "object" && "code" in e ? e : { code: -1, message: String(e?.message || e) };
ws.send(JSON.stringify({ jsonrpc: "2.0", id: m.id, error }));
}
});
}
// ─── HTTP ───────────────────────────────────────────────────────────────────
const cookies = (req) => Object.fromEntries(String(req.headers.cookie ?? "").split(/;\s*/).filter(Boolean).map((c) => { const i = c.indexOf("="); return [c.slice(0, i), c.slice(i + 1)]; }));
const pageApp = () => `<!doctype html><html><head><meta charset="utf-8"><title>Qlik Sense</title></head><body>
<div id="qv-page-container">faux client Qlik</div>
<script>
// Le vrai client ouvre son websocket Engine avec le jeton CSRF dans l'URL.
console.log("client Qlik : démarrage");
const ws = new WebSocket("wss://" + location.host + "/app/${APP}?reloadUri=" + encodeURIComponent(location.href) + "&qlik-csrf-token=${encodeURIComponent(JETON)}");
ws.onopen = () => console.log("client Qlik : websocket ouvert");
</script></body></html>`;
const serveur = https.createServer({ key: fs.readFileSync(path.join(ICI, "key.pem")), cert: fs.readFileSync(path.join(ICI, "cert.pem")) }, (req, res) => {
const url = new URL(req.url, "https://localhost");
const auth = String(req.headers.authorization ?? "");
if (url.pathname === "/hub/" && url.searchParams.has("qlikTicket")) {
stats.ticket++;
if (url.searchParams.get("qlikTicket") !== "TICKET-OK") { res.writeHead(403); return res.end("ticket"); }
res.writeHead(302, { Location: "/hub/", "Set-Cookie": `X-Qlik-Session=${SESSION}; Path=/; HttpOnly; Secure` });
return res.end();
}
if (url.pathname === "/hub/") {
res.writeHead(302, { Location: "https://localhost/forms/?targetId=1a2b3c4d-0000-1111-2222-333344445555" });
return res.end();
}
if (url.pathname === "/internal_windows_authentication/") {
if (auth.startsWith("NTLM TlRMTVNTUAAB")) {
stats.ntlm1++;
res.writeHead(401, { "WWW-Authenticate": ["Negotiate", "NTLM " + TYPE2], Connection: "keep-alive", "Content-Length": 0 });
return res.end();
}
if (auth.startsWith("NTLM TlRMTVNTUAAD")) {
stats.ntlm3++;
res.writeHead(302, { Location: "https://localhost/hub/?qlikTicket=TICKET-OK" });
return res.end();
}
res.writeHead(401, { "WWW-Authenticate": ["Negotiate", "NTLM"], "Content-Length": 0 });
return res.end();
}
if (url.pathname.startsWith("/sense/app/")) {
if (cookies(req)["X-Qlik-Session"] !== SESSION) { stats.pageRefusee++; res.writeHead(302, { Location: "/hub/" }); return res.end(); }
stats.page++;
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
return res.end(pageApp());
}
if (url.pathname === "/__stats") { res.writeHead(200, { "Content-Type": "application/json" }); return res.end(JSON.stringify(stats)); }
res.writeHead(404); res.end("introuvable");
});
const wss = new WebSocketServer({ noServer: true, maxPayload: 512 * 1024 * 1024 });
serveur.on("upgrade", (req, socket, head) => {
const url = new URL(req.url, "https://localhost");
const ok = url.pathname === "/app/" + APP && cookies(req)["X-Qlik-Session"] === SESSION && url.searchParams.get("qlik-csrf-token") === JETON;
if (!ok) {
stats.wsRefuse++;
log("websocket refusé", url.pathname, "cookie=" + (cookies(req)["X-Qlik-Session"] === SESSION), "jeton=" + (url.searchParams.get("qlik-csrf-token") === JETON));
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
return socket.destroy();
}
stats.ws++;
wss.handleUpgrade(req, socket, head, (ws) => nouvelleSession(ws));
});
const PORT = Number(process.env.PORT || 443);
serveur.listen(PORT, "127.0.0.1", () => log(`à l'écoute sur https://localhost:${PORT}`));
+39 -6
View File
@@ -292,22 +292,28 @@ function SectionFfApi() {
const [savedUrl, setSavedUrl] = useState<string | null>(null); const [savedUrl, setSavedUrl] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [testedUrl, setTestedUrl] = useState<string | null>(null); const [testedUrl, setTestedUrl] = useState<string | null>(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(() => { useEffect(() => {
loadSavedConfig() loadSavedConfig()
.then((cfg) => { .then((cfg) => {
setSavedUrl(cfg.ffApiBaseUrl || null); setSavedUrl(cfg.ffApiBaseUrl || null);
if (cfg.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl); if (cfg.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
setCleEnregistree(cfg.ffApiKeyEnregistree);
}) })
.catch(() => { /* réglage optionnel ; l'échec de lecture est signalé par la page */ }); .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); setLoading(true);
setError(null); setError(null);
setStatus(null); setStatus(null);
testFfApiConnection(candidate) testFfApiConnection(candidate, cleCandidate)
.then((res) => { .then((res) => {
setTestedUrl(res.url); setTestedUrl(res.url);
if (res.success) setStatus(res.status as FfSyncStatus); if (res.success) setStatus(res.status as FfSyncStatus);
@@ -322,13 +328,17 @@ function SectionFfApi() {
const save = async () => { const save = async () => {
setSaving(true); setSaving(true);
try { try {
const res = await saveFfApiSettings(url); const res = await saveFfApiSettings(url, cle || undefined);
if (!res.success) { if (!res.success) {
toast.erreur(res.error ?? "Enregistrement impossible."); toast.erreur(res.error ?? "Enregistrement impossible.");
return; return;
} }
setSavedUrl(url.trim() || null); 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); runTest(url);
} catch (e) { } catch (e) {
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`); toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
@@ -361,13 +371,36 @@ function SectionFfApi() {
className="w-full font-mono" className="w-full font-mono"
/> />
</Champ> </Champ>
<Champ
id="ff-cle"
label="Clé d'API"
aide={cleEnregistree ? "Laissez vide pour conserver la clé enregistrée." : "Clé fournie par l'administrateur de l'API FF Nancy, envoyée à chaque appel."}
>
<div className="relative">
<Input id="ff-cle" type={showCle ? "text" : "password"} placeholder={placeholderMotDePasse(cleEnregistree)} value={cle}
onChange={(e) => setCle(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void save(); }}
className="w-full pr-10 font-mono" autoComplete="new-password" />
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setShowCle((v) => !v)}
aria-label={showCle ? "Masquer la clé" : "Afficher la clé"}
title={showCle ? "Masquer la clé" : "Afficher la clé"}
className="absolute right-0.5 top-1/2 -translate-y-1/2 text-[var(--text-muted)]"
>
{showCle ? <EyeOff /> : <Eye />}
</Button>
</div>
</Champ>
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4"> <div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
<Button onClick={save} disabled={saving}> <Button onClick={save} disabled={saving}>
{saving ? <Loader2 className="animate-spin" /> : <Save />} {saving ? <Loader2 className="animate-spin" /> : <Save />}
Enregistrer Enregistrer
</Button> </Button>
<Button variant="outline" onClick={() => runTest(url)} disabled={loading}> <Button variant="outline" onClick={() => runTest(url, cle || undefined)} disabled={loading}>
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />} {loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Tester la connexion Tester la connexion
</Button> </Button>
+14 -6
View File
@@ -32,6 +32,8 @@ export interface ParametresAffichables {
} | null; } | null;
qlik: { host: string; user: string; motDePasseEnregistre: boolean }; qlik: { host: string; user: string; motDePasseEnregistre: boolean };
ffApiBaseUrl: string; 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 { export interface ResultatAction {
@@ -67,12 +69,18 @@ export async function testQlikConnection(qlikHost: string, qlikUser: string, qli
return callAction("testQlikConnection", [qlikHost, qlikUser, qlikPassword]); 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<ResultatAction> { * Enregistre l'URL de l'API FF Nancy (chaîne vide = revenir au défaut) et sa clé
return callAction("saveFfApiSettings", [ffApiBaseUrl]); * d'API (vide ou absente : celle qui est enregistrée est conservée).
*/
export async function saveFfApiSettings(ffApiBaseUrl: string, ffApiKey?: string): Promise<ResultatAction> {
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<DiagnosticApiFf> { * Teste l'API FF Nancy (diagnostic détaillé : DNS, refus, délai, code HTTP, clé).
return callAction("testFfApiConnection", [ffApiBaseUrl]); * Clé vide ou absente : on teste avec celle qui est enregistrée.
*/
export async function testFfApiConnection(ffApiBaseUrl?: string, ffApiKey?: string): Promise<DiagnosticApiFf> {
return callAction("testFfApiConnection", [ffApiBaseUrl, ffApiKey]);
} }