mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-13 18:27:40 +02:00
Serveur Rust : comptes, clés d'API, Paramètres, réglages de synchro, journaux
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
This commit is contained in:
10 files changed
+717
-7
No files matched your search
@@ -49,3 +49,4 @@ next-env.d.ts
|
||||
/server/target/
|
||||
# Vite
|
||||
/dist/
|
||||
/.claude/worktrees/
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Gestion des clés de l'API `/api/v1` (administrateurs) — port de `api-key-actions.ts`.
|
||||
//!
|
||||
//! La clé en clair n'existe qu'une fois : renvoyée par `createApiKey`, jamais
|
||||
//! restituée ensuite (seul son hachage SHA-256 est stocké). Une clé révoquée
|
||||
//! conserve sa ligne, pour garder la trace de son usage passé.
|
||||
|
||||
use crate::auth::api_key::generate_api_key;
|
||||
use crate::error::AppResult;
|
||||
use crate::rpc::{json, Ctx};
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json as j, Value};
|
||||
use sqlx::Row;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ApiKeyRow {
|
||||
id: i32,
|
||||
name: String,
|
||||
key_prefix: String,
|
||||
role: String,
|
||||
created_by: Option<String>,
|
||||
created_at: Option<String>,
|
||||
last_used_at: Option<String>,
|
||||
revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
fn ts(r: &sqlx::postgres::PgRow, col: &str) -> Option<String> {
|
||||
r.try_get::<Option<NaiveDateTime>, _>(col).ok().flatten().map(crate::db::iso)
|
||||
}
|
||||
|
||||
/// Liste les clés. Jamais de secret — seulement le préfixe lisible.
|
||||
pub async fn get_api_keys(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.admin().await?;
|
||||
let pool = crate::db::pool()?;
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, name, key_prefix, role, created_by, created_at, last_used_at, revoked_at FROM api_keys ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
let out: Vec<ApiKeyRow> = rows
|
||||
.iter()
|
||||
.map(|r| ApiKeyRow {
|
||||
id: r.try_get("id").unwrap_or_default(),
|
||||
name: r.try_get("name").unwrap_or_default(),
|
||||
key_prefix: r.try_get("key_prefix").unwrap_or_default(),
|
||||
role: r.try_get("role").unwrap_or_default(),
|
||||
created_by: r.try_get("created_by").ok().flatten(),
|
||||
created_at: ts(r, "created_at"),
|
||||
last_used_at: ts(r, "last_used_at"),
|
||||
revoked_at: ts(r, "revoked_at"),
|
||||
})
|
||||
.collect();
|
||||
json(out)
|
||||
}
|
||||
|
||||
/// Crée une clé et renvoie sa valeur **en clair, une seule et unique fois**.
|
||||
pub async fn create_api_key(ctx: Ctx) -> AppResult<Value> {
|
||||
let moi = ctx.admin().await?;
|
||||
let name: String = ctx.arg::<Option<String>>(0)?.unwrap_or_default();
|
||||
let role: String = ctx.arg::<Option<String>>(1)?.unwrap_or_else(|| "user".into());
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(j!({ "success": false, "error": "Le nom de la clé est obligatoire." }));
|
||||
}
|
||||
if trimmed.chars().count() > 100 {
|
||||
return Ok(j!({ "success": false, "error": "Nom trop long (100 caractères max)." }));
|
||||
}
|
||||
let k = generate_api_key();
|
||||
let pool = crate::db::pool()?;
|
||||
let created_by = Some(moi.username.clone()).filter(|s| !s.is_empty());
|
||||
match sqlx::query("INSERT INTO api_keys (name, key_prefix, key_hash, role, created_by) VALUES ($1, $2, $3, $4, $5)")
|
||||
.bind(trimmed)
|
||||
.bind(&k.key_prefix)
|
||||
.bind(&k.key_hash)
|
||||
.bind(&role)
|
||||
.bind(created_by)
|
||||
.execute(&pool)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(j!({ "success": true, "key": k.key, "keyPrefix": k.key_prefix })),
|
||||
Err(e) => {
|
||||
tracing::error!("[api-keys] création KO: {e}");
|
||||
Ok(j!({ "success": false, "error": "Erreur technique lors de la création de la clé." }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Révoque une clé : refusée dès l'appel suivant, mais toujours listée.
|
||||
pub async fn revoke_api_key(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.admin().await?;
|
||||
let id: i64 = ctx.arg(0)?;
|
||||
let pool = crate::db::pool()?;
|
||||
match sqlx::query("UPDATE api_keys SET revoked_at = now() WHERE id = $1").bind(id as i32).execute(&pool).await {
|
||||
Ok(_) => Ok(j!({ "success": true })),
|
||||
Err(e) => {
|
||||
tracing::error!("[api-keys] révocation KO: {e}");
|
||||
Ok(j!({ "success": false, "error": "Erreur lors de la révocation." }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime définitivement une clé (nettoyage de la liste).
|
||||
pub async fn delete_api_key(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.admin().await?;
|
||||
let id: i64 = ctx.arg(0)?;
|
||||
let pool = crate::db::pool()?;
|
||||
match sqlx::query("DELETE FROM api_keys WHERE id = $1").bind(id as i32).execute(&pool).await {
|
||||
Ok(_) => Ok(j!({ "success": true })),
|
||||
Err(e) => {
|
||||
tracing::error!("[api-keys] suppression KO: {e}");
|
||||
Ok(j!({ "success": false, "error": "Erreur lors de la suppression." }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Journaux serveur des jobs (liste + téléchargement).
|
||||
//!
|
||||
//! `GET /api/logs` → liste des journaux disponibles (JSON)
|
||||
//! `GET /api/logs?id=<jobId>` → le journal complet en pièce jointe (text/plain)
|
||||
//!
|
||||
//! Réservé aux admins : un journal d'extraction contient les diagnostics du
|
||||
//! modèle Qlik et les codes articles manipulés (jamais de secret).
|
||||
|
||||
use crate::auth::AdminUser;
|
||||
use crate::logcapture::{list_captures, read_capture};
|
||||
use axum::extract::Query;
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LogsQuery {
|
||||
id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_logs(_admin: AdminUser, Query(q): Query<LogsQuery>) -> Response {
|
||||
let Some(id) = q.id.filter(|s| !s.is_empty()) else {
|
||||
return Json(json!({ "success": true, "logs": list_captures() })).into_response();
|
||||
};
|
||||
let Some(contenu) = read_capture(&id) else {
|
||||
return (StatusCode::NOT_FOUND, Json(json!({ "error": "Journal introuvable ou expiré" }))).into_response();
|
||||
};
|
||||
// Nom de fichier assaini : l'id finit dans un en-tête HTTP.
|
||||
let nom: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || "_.-".contains(c) { c } else { '_' }).collect();
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/plain; charset=utf-8".to_string()),
|
||||
(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{nom}.log\"")),
|
||||
(header::CACHE_CONTROL, "no-store".to_string()),
|
||||
],
|
||||
contenu,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
+44
-4
@@ -1,16 +1,56 @@
|
||||
//! Module `admin` — à porter.
|
||||
//! Module `admin` — comptes, clés d'API, Paramètres, synchronisation nocturne.
|
||||
|
||||
use crate::rpc::Handler;
|
||||
pub mod api_keys;
|
||||
pub mod logs;
|
||||
mod qlik_bridge;
|
||||
pub mod settings;
|
||||
pub mod sync_settings;
|
||||
pub mod users;
|
||||
|
||||
use crate::error::AppResult;
|
||||
use crate::handler;
|
||||
use crate::rpc::{json, Ctx, Handler};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// Routes HTTP du module.
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
Router::new().route("/api/logs", get(logs::get_logs))
|
||||
}
|
||||
|
||||
async fn read_sync_settings_action(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.admin().await?;
|
||||
json(sync_settings::read_sync_settings())
|
||||
}
|
||||
|
||||
async fn save_sync_settings_action(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.admin().await?;
|
||||
let patch: Map<String, Value> = ctx.arg::<Option<Map<String, Value>>>(0)?.unwrap_or_default();
|
||||
json(sync_settings::save_sync_settings(&patch)?)
|
||||
}
|
||||
|
||||
/// Actions (`POST /api/actions/{nom}`) du module.
|
||||
pub fn actions() -> Vec<(&'static str, Handler)> {
|
||||
vec![]
|
||||
vec![
|
||||
("getUsers", handler!(users::get_users)),
|
||||
("createUser", handler!(users::create_user)),
|
||||
("deleteUser", handler!(users::delete_user)),
|
||||
("updatePassword", handler!(users::update_password)),
|
||||
("getApiKeys", handler!(api_keys::get_api_keys)),
|
||||
("createApiKey", handler!(api_keys::create_api_key)),
|
||||
("revokeApiKey", handler!(api_keys::revoke_api_key)),
|
||||
("deleteApiKey", handler!(api_keys::delete_api_key)),
|
||||
("getParametresAffichables", handler!(settings::get_parametres_affichables)),
|
||||
("testDatabaseConnection", handler!(settings::test_database_connection)),
|
||||
("saveDatabaseSettings", handler!(settings::save_database_settings)),
|
||||
("saveQlikSettings", handler!(settings::save_qlik_settings)),
|
||||
("testQlikConnection", handler!(settings::test_qlik_connection)),
|
||||
("saveFfApiSettings", handler!(settings::save_ff_api_settings)),
|
||||
("testFfApiConnection", handler!(settings::test_ff_api_connection)),
|
||||
("readSyncSettings", handler!(read_sync_settings_action)),
|
||||
("saveSyncSettings", handler!(save_sync_settings_action)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Données de pages (`GET /api/pages/{nom}`) du module.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Points d'appel vers les modules `qlik` et `ff` utilisés par les Paramètres.
|
||||
//! Branchés après l'intégration de ces modules.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Ouvre une session Qlik (NTLM → ticket → cookie) avec les identifiants donnés.
|
||||
pub async fn tester_session(_host: String, _user: String, _password: Option<String>) -> anyhow::Result<()> {
|
||||
anyhow::bail!("Test Qlik indisponible : module en cours de portage")
|
||||
}
|
||||
|
||||
/// Oublie l'URL mémorisée de l'API FF Nancy.
|
||||
pub fn reset_ff_api_base() {}
|
||||
|
||||
/// Diagnostic de l'API FF Nancy.
|
||||
pub async fn diagnostiquer_api_ff(base: Option<String>) -> Value {
|
||||
json!({ "success": false, "url": base.unwrap_or_default(), "error": "Diagnostic indisponible : module en cours de portage" })
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Actions des Paramètres — réservées aux administrateurs.
|
||||
//!
|
||||
//! Aucun secret ne repart vers le navigateur : les mots de passe (base, Qlik)
|
||||
//! et les clés d'IA restent dans `data/.db-config.json`. Un mot de passe laissé
|
||||
//! vide dans le formulaire signifie « conserver celui qui est enregistré ».
|
||||
|
||||
use crate::config::{read_config, write_config};
|
||||
use crate::error::AppResult;
|
||||
use crate::rpc::{json, Ctx};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json as j, Map, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ChampsBaseDeDonnees {
|
||||
#[serde(default)]
|
||||
pub host: String,
|
||||
#[serde(default)]
|
||||
pub port: String,
|
||||
#[serde(default)]
|
||||
pub database: String,
|
||||
#[serde(default)]
|
||||
pub user: String,
|
||||
/// Vide ou absent : le mot de passe enregistré est conservé.
|
||||
#[serde(default)]
|
||||
pub password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssl: bool,
|
||||
}
|
||||
|
||||
struct UrlPostgres {
|
||||
host: String,
|
||||
port: String,
|
||||
database: String,
|
||||
user: String,
|
||||
password: String,
|
||||
ssl: bool,
|
||||
}
|
||||
|
||||
fn decoder(v: &str) -> String {
|
||||
urlencoding::decode(v).map(|c| c.into_owned()).unwrap_or_else(|_| v.to_string())
|
||||
}
|
||||
|
||||
/// Découpe l'URL `postgres://` enregistrée en champs du formulaire.
|
||||
fn lire_url_postgres(url: &str) -> Option<UrlPostgres> {
|
||||
let re = regex::Regex::new(r"^[a-zA-Z][a-zA-Z0-9+.-]*://(?:([^:@/]*)(?::([^@/]*))?@)?(\[[^\]]+\]|[^:/?#]*)(?::(\d+))?(?:/([^?#]*))?").unwrap();
|
||||
let Some(c) = re.captures(url) else {
|
||||
tracing::error!("[Settings] URL de base enregistrée illisible");
|
||||
return None;
|
||||
};
|
||||
Some(UrlPostgres {
|
||||
host: c.get(3).map(|m| m.as_str().to_string()).unwrap_or_default(),
|
||||
port: c.get(4).map(|m| m.as_str().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "5432".into()),
|
||||
database: decoder(c.get(5).map(|m| m.as_str()).unwrap_or_default()),
|
||||
user: decoder(c.get(1).map(|m| m.as_str()).unwrap_or_default()),
|
||||
password: decoder(c.get(2).map(|m| m.as_str()).unwrap_or_default()),
|
||||
ssl: url.contains("sslmode=require"),
|
||||
})
|
||||
}
|
||||
|
||||
fn construire_url_postgres(champs: &ChampsBaseDeDonnees, mot_de_passe: &str) -> String {
|
||||
let user = urlencoding::encode(champs.user.trim()).into_owned();
|
||||
let identifiants = if mot_de_passe.is_empty() {
|
||||
user
|
||||
} else {
|
||||
format!("{user}:{}", urlencoding::encode(mot_de_passe))
|
||||
};
|
||||
let port = Some(champs.port.trim()).filter(|p| !p.is_empty()).unwrap_or("5432");
|
||||
format!(
|
||||
"postgres://{identifiants}@{}:{port}/{}{}",
|
||||
champs.host.trim(),
|
||||
champs.database.trim(),
|
||||
if champs.ssl { "?sslmode=require" } else { "" }
|
||||
)
|
||||
}
|
||||
|
||||
/// Mot de passe saisi, sinon celui de l'URL enregistrée.
|
||||
fn mot_de_passe_base(champs: &ChampsBaseDeDonnees) -> String {
|
||||
if let Some(p) = champs.password.as_ref().filter(|p| !p.is_empty()) {
|
||||
return p.clone();
|
||||
}
|
||||
read_config().url.as_deref().and_then(lire_url_postgres).map(|u| u.password).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DbAffichable {
|
||||
host: String,
|
||||
port: String,
|
||||
database: String,
|
||||
user: String,
|
||||
ssl: bool,
|
||||
mot_de_passe_enregistre: bool,
|
||||
}
|
||||
|
||||
/// Réglages enregistrés, sans aucun secret.
|
||||
pub async fn get_parametres_affichables(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.admin().await?;
|
||||
let config = read_config();
|
||||
let base = config.url.as_deref().and_then(lire_url_postgres);
|
||||
let db = base.map(|b| DbAffichable {
|
||||
host: b.host,
|
||||
port: b.port,
|
||||
database: b.database,
|
||||
user: b.user,
|
||||
ssl: b.ssl,
|
||||
mot_de_passe_enregistre: !b.password.is_empty(),
|
||||
});
|
||||
let qlik_pwd_env = std::env::var("QLIK_PWD").map(|v| !v.is_empty()).unwrap_or(false);
|
||||
json(j!({
|
||||
"db": db,
|
||||
"qlik": {
|
||||
"host": config.qlik_host.clone().unwrap_or_default(),
|
||||
"user": config.qlik_user.clone().unwrap_or_default(),
|
||||
// Même ordre de priorité que la config Qlik : réglage enregistré, puis variable d'environnement.
|
||||
"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(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn refus(e: crate::error::AppError) -> Value {
|
||||
j!({ "success": false, "error": e.message })
|
||||
}
|
||||
|
||||
pub async fn test_database_connection(ctx: Ctx) -> AppResult<Value> {
|
||||
if let Err(e) = ctx.admin().await {
|
||||
return Ok(refus(e));
|
||||
}
|
||||
let champs: ChampsBaseDeDonnees = ctx.arg(0)?;
|
||||
let url = construire_url_postgres(&champs, &mot_de_passe_base(&champs));
|
||||
tracing::info!("Testing connection to: {}", crate::db::mask_url(&url));
|
||||
let essai = async {
|
||||
let opts = crate::db::connect_options(&url)?;
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.acquire_timeout(Duration::from_secs(5))
|
||||
.connect_with(opts)
|
||||
.await?;
|
||||
sqlx::query("SELECT 1").execute(&pool).await?;
|
||||
pool.close().await;
|
||||
Ok::<_, sqlx::Error>(())
|
||||
};
|
||||
match tokio::time::timeout(Duration::from_secs(8), essai).await {
|
||||
Ok(Ok(())) => Ok(j!({ "success": true })),
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!("Database connection test failed: {e}");
|
||||
Ok(j!({ "success": false, "error": e.to_string() }))
|
||||
}
|
||||
Err(_) => Ok(j!({ "success": false, "error": "timeout exceeded when trying to connect" })),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_database_settings(ctx: Ctx) -> AppResult<Value> {
|
||||
if let Err(e) = ctx.admin().await {
|
||||
return Ok(refus(e));
|
||||
}
|
||||
let champs: ChampsBaseDeDonnees = ctx.arg(0)?;
|
||||
let url = construire_url_postgres(&champs, &mot_de_passe_base(&champs));
|
||||
// Seule l'URL change : les clés d'IA et les réglages Qlik / API FF sont conservés.
|
||||
tracing::info!("[Settings] Saving config to {}", crate::config::config_file().display());
|
||||
let mut patch = Map::new();
|
||||
patch.insert("url".into(), Value::String(url));
|
||||
match write_config(patch) {
|
||||
Ok(()) => {
|
||||
tracing::info!("[Settings] Database configuration saved successfully.");
|
||||
crate::db::refresh();
|
||||
// Nouvelle base : schéma et compte admin à (re)vérifier.
|
||||
tokio::spawn(async {
|
||||
crate::migrations::run().await;
|
||||
crate::auth::ensure_admin_exists().await;
|
||||
});
|
||||
Ok(j!({ "success": true }))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to save database configuration: {e}");
|
||||
Ok(j!({ "success": false, "error": e.to_string() }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mot de passe vide ou absent : celui qui est enregistré est conservé.
|
||||
pub async fn save_qlik_settings(ctx: Ctx) -> AppResult<Value> {
|
||||
if let Err(e) = ctx.admin().await {
|
||||
return Ok(refus(e));
|
||||
}
|
||||
let host: String = ctx.arg::<Option<String>>(0)?.unwrap_or_default();
|
||||
let user: String = ctx.arg::<Option<String>>(1)?.unwrap_or_default();
|
||||
let password: Option<String> = ctx.arg::<Option<String>>(2)?.filter(|p| !p.is_empty());
|
||||
let mut patch = Map::new();
|
||||
patch.insert("qlikHost".into(), Value::String(host));
|
||||
patch.insert("qlikUser".into(), Value::String(user));
|
||||
if let Some(p) = password {
|
||||
patch.insert("qlikPassword".into(), Value::String(p));
|
||||
}
|
||||
match write_config(patch) {
|
||||
Ok(()) => {
|
||||
tracing::info!("[Settings] Qlik configuration saved.");
|
||||
Ok(j!({ "success": true }))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[Settings] Failed to save Qlik config: {e}");
|
||||
Ok(j!({ "success": false, "error": e.to_string() }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mot de passe vide ou absent : on teste avec celui qui est enregistré.
|
||||
pub async fn test_qlik_connection(ctx: Ctx) -> AppResult<Value> {
|
||||
if let Err(e) = ctx.admin().await {
|
||||
return Ok(refus(e));
|
||||
}
|
||||
let host: String = ctx.arg::<Option<String>>(0)?.unwrap_or_default();
|
||||
let user: String = ctx.arg::<Option<String>>(1)?.unwrap_or_default();
|
||||
let password: Option<String> = ctx.arg::<Option<String>>(2)?.filter(|p| !p.is_empty());
|
||||
match super::qlik_bridge::tester_session(host, user, password).await {
|
||||
Ok(()) => Ok(j!({ "success": true })),
|
||||
Err(e) => Ok(j!({ "success": false, "error": format!("{e:#}") })),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut.
|
||||
pub async fn save_ff_api_settings(ctx: Ctx) -> AppResult<Value> {
|
||||
if let Err(e) = ctx.admin().await {
|
||||
return Ok(refus(e));
|
||||
}
|
||||
let brut: String = ctx.arg::<Option<String>>(0)?.unwrap_or_default();
|
||||
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://")) {
|
||||
return Ok(j!({ "success": false, "error": "L'URL doit commencer par http:// ou https://" }));
|
||||
}
|
||||
let mut patch = Map::new();
|
||||
patch.insert("ffApiBaseUrl".into(), if cleaned.is_empty() { Value::Null } else { Value::String(cleaned.clone()) });
|
||||
match write_config(patch) {
|
||||
Ok(()) => {
|
||||
// Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde.
|
||||
super::qlik_bridge::reset_ff_api_base();
|
||||
tracing::info!("[Settings] FF API base URL saved: {}", if cleaned.is_empty() { "(défaut)" } else { &cleaned });
|
||||
Ok(j!({ "success": true }))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[Settings] Failed to save FF API config: {e}");
|
||||
Ok(j!({ "success": false, "error": e.to_string() }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Teste l'API FF Nancy.
|
||||
pub async fn test_ff_api_connection(ctx: Ctx) -> AppResult<Value> {
|
||||
if let Err(e) = ctx.admin().await {
|
||||
return Ok(j!({ "success": false, "url": "", "error": e.message }));
|
||||
}
|
||||
let base: Option<String> = ctx.arg(0)?;
|
||||
Ok(super::qlik_bridge::diagnostiquer_api_ff(base).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn url_roundtrip() {
|
||||
let champs = ChampsBaseDeDonnees {
|
||||
host: "db".into(),
|
||||
port: "".into(),
|
||||
database: "cf".into(),
|
||||
user: "u@x".into(),
|
||||
password: Some("p:ss/w".into()),
|
||||
ssl: true,
|
||||
};
|
||||
let url = construire_url_postgres(&champs, "p:ss/w");
|
||||
assert_eq!(url, "postgres://u%40x:p%3Ass%2Fw@db:5432/cf?sslmode=require");
|
||||
let u = lire_url_postgres(&url).unwrap();
|
||||
assert_eq!((u.host.as_str(), u.port.as_str(), u.database.as_str(), u.user.as_str(), u.password.as_str(), u.ssl), ("db", "5432", "cf", "u@x", "p:ss/w", true));
|
||||
let u = lire_url_postgres("postgres://postgres:secret@postgresbase:5432/collectflow").unwrap();
|
||||
assert_eq!((u.host.as_str(), u.user.as_str(), u.password.as_str(), u.database.as_str()), ("postgresbase", "postgres", "secret", "collectflow"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Réglages de la synchronisation nocturne, rangés dans `data/.db-config.json`
|
||||
//! (clé `syncNuit`) comme les autres réglages de l'application.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncSettings {
|
||||
/// Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter
|
||||
/// Qlik et la base la nuit suivante sans que personne l'ait décidé.
|
||||
pub actif: bool,
|
||||
/// Heure d'ouverture de la fenêtre (0-23).
|
||||
pub heure_debut: i64,
|
||||
/// Heure de fermeture. Aucun fournisseur n'est **démarré** après.
|
||||
pub heure_fin: i64,
|
||||
/// Nombre maximum d'extractions Qlik par nuit (chacune peut prendre 8 min).
|
||||
pub qlik_par_nuit: i64,
|
||||
/// Une extraction Qlik est intercalée tous les N fournisseurs SQL.
|
||||
pub sql_par_qlik: i64,
|
||||
/// Âge minimal des données Qlik avant de les refaire.
|
||||
pub qlik_min_jours: i64,
|
||||
}
|
||||
|
||||
pub const SYNC_SETTINGS_DEFAUT: SyncSettings =
|
||||
SyncSettings { actif: false, heure_debut: 0, heure_fin: 5, qlik_par_nuit: 6, sql_par_qlik: 20, qlik_min_jours: 7 };
|
||||
|
||||
fn borne(v: Option<&Value>, min: i64, max: i64, defaut: i64) -> i64 {
|
||||
let n = match v {
|
||||
Some(Value::Number(n)) => n.as_f64(),
|
||||
Some(Value::String(s)) => s.trim().parse::<f64>().ok(),
|
||||
Some(Value::Bool(b)) => Some(if *b { 1.0 } else { 0.0 }),
|
||||
Some(Value::Null) => Some(0.0),
|
||||
_ => None,
|
||||
};
|
||||
match n {
|
||||
Some(f) if f.is_finite() => (f.trunc() as i64).clamp(min, max),
|
||||
_ => defaut,
|
||||
}
|
||||
}
|
||||
|
||||
fn truthy(v: Option<&Value>) -> bool {
|
||||
match v {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(false),
|
||||
Some(Value::String(s)) => !s.is_empty(),
|
||||
Some(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalise ce qui vient du fichier : il est éditable à la main.
|
||||
pub fn normaliser(brut: Option<&Map<String, Value>>) -> SyncSettings {
|
||||
let d = SYNC_SETTINGS_DEFAUT;
|
||||
let g = |k: &str| brut.and_then(|m| m.get(k));
|
||||
let mut s = SyncSettings {
|
||||
actif: truthy(g("actif")),
|
||||
heure_debut: borne(g("heureDebut"), 0, 23, d.heure_debut),
|
||||
heure_fin: borne(g("heureFin"), 0, 23, d.heure_fin),
|
||||
qlik_par_nuit: borne(g("qlikParNuit"), 0, 200, d.qlik_par_nuit),
|
||||
sql_par_qlik: borne(g("sqlParQlik"), 1, 500, d.sql_par_qlik),
|
||||
qlik_min_jours: borne(g("qlikMinJours"), 0, 365, d.qlik_min_jours),
|
||||
};
|
||||
// Une fenêtre de durée nulle ne s'ouvrirait jamais : retour au défaut.
|
||||
if s.heure_debut == s.heure_fin {
|
||||
s.heure_debut = d.heure_debut;
|
||||
s.heure_fin = d.heure_fin;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn lire_fichier() -> Option<Map<String, Value>> {
|
||||
let raw = std::fs::read_to_string(crate::config::config_file()).ok()?;
|
||||
match serde_json::from_str::<Value>(&raw).ok()? {
|
||||
Value::Object(m) => Some(m),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_sync_settings() -> SyncSettings {
|
||||
match lire_fichier() {
|
||||
Some(cfg) => normaliser(cfg.get("syncNuit").and_then(|v| v.as_object())),
|
||||
None => SYNC_SETTINGS_DEFAUT,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fusionne `patch` (champs camelCase partiels) dans les réglages enregistrés.
|
||||
pub fn save_sync_settings(patch: &Map<String, Value>) -> std::io::Result<SyncSettings> {
|
||||
let existant = lire_fichier().unwrap_or_default();
|
||||
let courant = normaliser(existant.get("syncNuit").and_then(|v| v.as_object()));
|
||||
let mut fusion = match serde_json::to_value(courant) {
|
||||
Ok(Value::Object(m)) => m,
|
||||
_ => Map::new(),
|
||||
};
|
||||
for (k, v) in patch {
|
||||
fusion.insert(k.clone(), v.clone());
|
||||
}
|
||||
let suivant = normaliser(Some(&fusion));
|
||||
let mut p = Map::new();
|
||||
p.insert("syncNuit".into(), serde_json::to_value(suivant).unwrap_or(Value::Null));
|
||||
crate::config::write_config(p)?;
|
||||
tracing::info!("[sync-nuit] réglages enregistrés : {}", serde_json::to_string(&suivant).unwrap_or_default());
|
||||
Ok(suivant)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn normalise() {
|
||||
let m = json!({ "actif": 1, "heureDebut": "2", "heureFin": 30, "qlikParNuit": -4, "sqlParQlik": 0 });
|
||||
let s = normaliser(m.as_object());
|
||||
assert!(s.actif);
|
||||
assert_eq!((s.heure_debut, s.heure_fin, s.qlik_par_nuit, s.sql_par_qlik, s.qlik_min_jours), (2, 23, 0, 1, 7));
|
||||
let m = json!({ "heureDebut": 4, "heureFin": 4 });
|
||||
let s = normaliser(m.as_object());
|
||||
assert_eq!((s.heure_debut, s.heure_fin), (0, 5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Gestion des comptes (administrateurs) — port de `user-actions.ts`.
|
||||
|
||||
use crate::auth::password::hash_password;
|
||||
use crate::error::AppResult;
|
||||
use crate::rpc::{json, Ctx};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json as j, Value};
|
||||
use sqlx::Row;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UserRow {
|
||||
id: i32,
|
||||
username: String,
|
||||
role: String,
|
||||
created_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Tous les utilisateurs.
|
||||
pub async fn get_users(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.admin().await?;
|
||||
let pool = crate::db::pool()?;
|
||||
let rows = sqlx::query("SELECT id, username, role, created_at FROM users").fetch_all(&pool).await?;
|
||||
let users: Vec<UserRow> = rows
|
||||
.iter()
|
||||
.map(|r| UserRow {
|
||||
id: r.try_get("id").unwrap_or_default(),
|
||||
username: r.try_get("username").unwrap_or_default(),
|
||||
role: r.try_get("role").unwrap_or_default(),
|
||||
created_at: r.try_get::<Option<chrono::NaiveDateTime>, _>("created_at").ok().flatten().map(crate::db::iso),
|
||||
})
|
||||
.collect();
|
||||
json(users)
|
||||
}
|
||||
|
||||
/// Crée un utilisateur.
|
||||
pub async fn create_user(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.admin().await?;
|
||||
let username: String = ctx.arg(0)?;
|
||||
let password: String = ctx.arg(1)?;
|
||||
let role: Option<String> = ctx.arg(2)?;
|
||||
let role = role.unwrap_or_else(|| "user".into());
|
||||
let pool = crate::db::pool()?;
|
||||
match sqlx::query("INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3)")
|
||||
.bind(&username)
|
||||
.bind(hash_password(&password))
|
||||
.bind(&role)
|
||||
.execute(&pool)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(j!({ "success": true })),
|
||||
Err(e) => {
|
||||
tracing::error!("CreateUser Error: {e}");
|
||||
Ok(j!({ "success": false, "error": "L'utilisateur existe déjà ou une erreur technique est survenue." }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime un utilisateur (jamais son propre compte).
|
||||
pub async fn delete_user(ctx: Ctx) -> AppResult<Value> {
|
||||
let moi = ctx.admin().await?;
|
||||
let id: i64 = ctx.arg(0)?;
|
||||
if moi.id.parse::<i64>().ok() == Some(id) {
|
||||
return Ok(j!({ "success": false, "error": "Vous ne pouvez pas supprimer votre propre compte." }));
|
||||
}
|
||||
let pool = crate::db::pool()?;
|
||||
match sqlx::query("DELETE FROM users WHERE id = $1").bind(id as i32).execute(&pool).await {
|
||||
Ok(_) => {
|
||||
crate::auth::authz::invalider(&id.to_string());
|
||||
Ok(j!({ "success": true }))
|
||||
}
|
||||
Err(_) => Ok(j!({ "success": false, "error": "Erreur lors de la suppression." })),
|
||||
}
|
||||
}
|
||||
|
||||
/// Change le mot de passe d'un utilisateur.
|
||||
pub async fn update_password(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.admin().await?;
|
||||
let id: i64 = ctx.arg(0)?;
|
||||
let new_password: String = ctx.arg(1)?;
|
||||
let pool = crate::db::pool()?;
|
||||
match sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
|
||||
.bind(hash_password(&new_password))
|
||||
.bind(id as i32)
|
||||
.execute(&pool)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(j!({ "success": true })),
|
||||
Err(_) => Ok(j!({ "success": false, "error": "Erreur lors de la mise à jour du mot de passe." })),
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ where
|
||||
Fut: Future<Output = anyhow::Result<T>> + Send + 'static,
|
||||
{
|
||||
let pending = {
|
||||
let mut store = STORE.lock();
|
||||
let store = STORE.lock();
|
||||
match store.map.get(key) {
|
||||
Some(Entry::Ready { value, expires_at, .. }) if *expires_at > Instant::now() => {
|
||||
if let Some(v) = value.downcast_ref::<T>() {
|
||||
|
||||
+7
-2
@@ -98,9 +98,14 @@ fn no_store(v: Value) -> Response {
|
||||
([(header::CACHE_CONTROL, "no-store")], Json(v)).into_response()
|
||||
}
|
||||
|
||||
async fn call_action(Path(name): Path<String>, headers: HeaderMap, body: Option<Json<Value>>) -> AppResult<Response> {
|
||||
async fn call_action(Path(name): Path<String>, headers: HeaderMap, body: axum::body::Bytes) -> AppResult<Response> {
|
||||
let h = ACTIONS.get(name.as_str()).ok_or_else(|| AppError::not_found(format!("Action inconnue : {name}")))?;
|
||||
let args = match body.map(|b| b.0) {
|
||||
let body: Option<Value> = if body.iter().all(|b| b.is_ascii_whitespace()) {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::from_slice(&body).map_err(|e| AppError::bad_request(format!("Corps JSON invalide : {e}")))?)
|
||||
};
|
||||
let args = match body {
|
||||
Some(Value::Array(a)) => a,
|
||||
Some(Value::Null) | None => vec![],
|
||||
Some(other) => vec![other],
|
||||
|
||||
Reference in new issue
Block a user