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:
Claude committed 2026-10-12 19:02:08 +00:00
1 parent 559e6ed714
commit a878270081
10 files changed
+717 -7

No files matched your search

+1
View File
@@ -49,3 +49,4 @@ next-env.d.ts
/server/target/
# Vite
/dist/
/.claude/worktrees/
+115
View File
@@ -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." }))
}
}
}
+41
View File
@@ -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
View File
@@ -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.
+17
View File
@@ -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" })
}
+279
View File
@@ -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"));
}
}
+121
View File
@@ -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));
}
}
+91
View File
@@ -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." })),
}
}
+1 -1
View File
@@ -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
View File
@@ -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],