mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-14 18:58:17 +02:00
Serveur Rust : socle (config, base, auth, sessions, journaux, cache FF, SPA)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
This commit is contained in:
28 files changed
+5873
No files matched your search
@@ -0,0 +1,22 @@
|
||||
//! Module `admin` — à porter.
|
||||
|
||||
use crate::rpc::Handler;
|
||||
use axum::Router;
|
||||
|
||||
/// Routes HTTP du module.
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
}
|
||||
|
||||
/// Actions (`POST /api/actions/{nom}`) du module.
|
||||
pub fn actions() -> Vec<(&'static str, Handler)> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Données de pages (`GET /api/pages/{nom}`) du module.
|
||||
pub fn pages() -> Vec<(&'static str, Handler)> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Tâches de fond lancées au démarrage (minuteur de la synchro nocturne).
|
||||
pub fn start_background_tasks() {}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Enveloppe de réponse de l'API publique `/api/v1`.
|
||||
//!
|
||||
//! Une forme unique : `{ data, pagination?, meta }` en succès,
|
||||
//! `{ error: { code, message, details? } }` en échec, avec de vrais statuts HTTP.
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ApiErrorCode {
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
BadRequest,
|
||||
NotFound,
|
||||
/// Donnée pas encore calculée : 202, ce n'est pas un échec.
|
||||
NotReady,
|
||||
InternalError,
|
||||
}
|
||||
|
||||
impl ApiErrorCode {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unauthorized => "unauthorized",
|
||||
Self::Forbidden => "forbidden",
|
||||
Self::BadRequest => "bad_request",
|
||||
Self::NotFound => "not_found",
|
||||
Self::NotReady => "not_ready",
|
||||
Self::InternalError => "internal_error",
|
||||
}
|
||||
}
|
||||
pub fn status(self) -> StatusCode {
|
||||
match self {
|
||||
Self::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
Self::Forbidden => StatusCode::FORBIDDEN,
|
||||
Self::BadRequest => StatusCode::BAD_REQUEST,
|
||||
Self::NotFound => StatusCode::NOT_FOUND,
|
||||
Self::NotReady => StatusCode::ACCEPTED,
|
||||
Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Pagination {
|
||||
pub page: i64,
|
||||
pub limit: i64,
|
||||
pub total: i64,
|
||||
pub total_pages: i64,
|
||||
/// `true` s'il reste des pages à lire.
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
pub fn build_pagination(page: i64, limit: i64, total: i64) -> Pagination {
|
||||
let limit = limit.max(1);
|
||||
let total_pages = ((total as f64) / (limit as f64)).ceil().max(1.0) as i64;
|
||||
Pagination { page, limit, total, total_pages, has_more: page < total_pages }
|
||||
}
|
||||
|
||||
/// Réponse de succès. `meta` porte la fraîcheur de la donnée (`computedAt`).
|
||||
pub fn ok<T: Serialize>(data: T, pagination: Option<Pagination>, meta: Option<Map<String, Value>>) -> Response {
|
||||
let mut body = Map::new();
|
||||
body.insert("data".into(), serde_json::to_value(data).unwrap_or(Value::Null));
|
||||
if let Some(p) = pagination {
|
||||
body.insert("pagination".into(), serde_json::to_value(p).unwrap_or(Value::Null));
|
||||
}
|
||||
body.insert("meta".into(), Value::Object(meta.unwrap_or_default()));
|
||||
Json(Value::Object(body)).into_response()
|
||||
}
|
||||
|
||||
/// Réponse d'erreur. `details` sert notamment aux erreurs de validation.
|
||||
pub fn fail(code: ApiErrorCode, message: impl Into<String>, details: Option<Value>) -> Response {
|
||||
let mut err = json!({ "code": code.as_str(), "message": message.into() });
|
||||
if let Some(d) = details {
|
||||
err["details"] = d;
|
||||
}
|
||||
(code.status(), Json(json!({ "error": err }))).into_response()
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Authentification de l'API publique `/api/v1`.
|
||||
//!
|
||||
//! Deux voies : clé d'API (`X-API-Key` ou `Authorization: Bearer …`) pour les
|
||||
//! appelants non-navigateur, ou session du navigateur (l'interface utilise les
|
||||
//! mêmes routes). Échec → 401 JSON au format `/api/v1`, jamais de redirection.
|
||||
//!
|
||||
//! Seul le hachage SHA-256 des clés est stocké (table `api_keys`).
|
||||
|
||||
use crate::api_v1_response::{fail, ApiErrorCode};
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::response::Response;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use parking_lot::Mutex;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::Row;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
const KEY_PREFIX: &str = "cf_";
|
||||
const TRACE_USAGE: Duration = Duration::from_secs(60);
|
||||
static DERNIERE_TRACE: LazyLock<Mutex<HashMap<i32, Instant>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthContext {
|
||||
/// "api_key" | "session"
|
||||
pub via: &'static str,
|
||||
pub role: String,
|
||||
/// Nom de la clé, ou nom de l'utilisateur connecté.
|
||||
pub subject: String,
|
||||
}
|
||||
|
||||
impl AuthContext {
|
||||
/// Restreint un endpoint aux appelants administrateurs.
|
||||
pub fn require_admin(&self) -> Result<(), Response> {
|
||||
if self.role != "admin" {
|
||||
return Err(fail(ApiErrorCode::Forbidden, "Rôle administrateur requis pour cette opération.", None));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hash_api_key(key: &str) -> String {
|
||||
hex::encode(Sha256::digest(key.as_bytes()))
|
||||
}
|
||||
|
||||
pub struct GeneratedKey {
|
||||
pub key: String,
|
||||
pub key_hash: String,
|
||||
pub key_prefix: String,
|
||||
}
|
||||
|
||||
/// Génère une clé : en clair (affichée une seule fois), son hachage, et un préfixe lisible.
|
||||
pub fn generate_api_key() -> GeneratedKey {
|
||||
let mut buf = [0u8; 24];
|
||||
rand::thread_rng().fill_bytes(&mut buf);
|
||||
let key = format!("{KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(buf));
|
||||
GeneratedKey { key_hash: hash_api_key(&key), key_prefix: key.chars().take(11).collect(), key }
|
||||
}
|
||||
|
||||
fn hashes_equal(a: &str, b: &str) -> bool {
|
||||
match (hex::decode(a), hex::decode(b)) {
|
||||
(Ok(x), Ok(y)) => !x.is_empty() && x.len() == y.len() && bool::from(x.as_slice().ct_eq(y.as_slice())),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_key(headers: &HeaderMap) -> Option<String> {
|
||||
if let Some(v) = headers.get("x-api-key").and_then(|v| v.to_str().ok()) {
|
||||
if !v.trim().is_empty() {
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
let authz = headers.get("authorization").and_then(|v| v.to_str().ok())?;
|
||||
let re = regex::Regex::new(r"(?i)^Bearer\s+(.+)$").unwrap();
|
||||
re.captures(authz).map(|c| c[1].trim().to_string())
|
||||
}
|
||||
|
||||
/// Authentifie une requête `/api/v1`.
|
||||
pub async fn require_api_auth(headers: &HeaderMap) -> Result<AuthContext, Response> {
|
||||
if let Some(presented) = read_key(headers) {
|
||||
let presented_hash = hash_api_key(&presented);
|
||||
let pool = crate::db::pool().map_err(|e| fail(ApiErrorCode::InternalError, e.message, None))?;
|
||||
let row = sqlx::query("SELECT id, name, role, key_hash, revoked_at FROM api_keys WHERE key_hash = $1 LIMIT 1")
|
||||
.bind(&presented_hash)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| fail(ApiErrorCode::InternalError, e.to_string(), None))?;
|
||||
let Some(row) = row else {
|
||||
return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API invalide.", None));
|
||||
};
|
||||
let key_hash: String = row.try_get("key_hash").unwrap_or_default();
|
||||
if !hashes_equal(&key_hash, &presented_hash) {
|
||||
return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API invalide.", None));
|
||||
}
|
||||
let revoked: Option<chrono::NaiveDateTime> = row.try_get("revoked_at").ok().flatten();
|
||||
if revoked.is_some() {
|
||||
return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API révoquée.", None));
|
||||
}
|
||||
let id: i32 = row.try_get("id").unwrap_or_default();
|
||||
// Trace d'usage — jamais bloquante, au plus une écriture par minute et par clé.
|
||||
let tracer = {
|
||||
let mut m = DERNIERE_TRACE.lock();
|
||||
let due = m.get(&id).map(|t| t.elapsed() >= TRACE_USAGE).unwrap_or(true);
|
||||
if due {
|
||||
m.insert(id, Instant::now());
|
||||
}
|
||||
due
|
||||
};
|
||||
if tracer {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = sqlx::query("UPDATE api_keys SET last_used_at = now() WHERE id = $1").bind(id).execute(&pool).await {
|
||||
tracing::error!("[api-auth] lastUsedAt KO: {}", crate::error::short(&e, 120));
|
||||
}
|
||||
});
|
||||
}
|
||||
return Ok(AuthContext {
|
||||
via: "api_key",
|
||||
role: row.try_get("role").unwrap_or_else(|_| "user".into()),
|
||||
subject: row.try_get("name").unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
|
||||
// Repli sur la session du navigateur.
|
||||
if let Some(c) = super::session::from_headers(headers) {
|
||||
return Ok(AuthContext { via: "session", role: c.role, subject: c.name });
|
||||
}
|
||||
|
||||
Err(fail(
|
||||
ApiErrorCode::Unauthorized,
|
||||
"Authentification requise : fournissez un en-tête X-API-Key (ou Authorization: Bearer), ou connectez-vous.",
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
/// Extracteur pour les routes `/api/v1`.
|
||||
pub struct ApiAuth(pub AuthContext);
|
||||
|
||||
impl<S: Send + Sync> FromRequestParts<S> for ApiAuth {
|
||||
type Rejection = Response;
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
Ok(ApiAuth(require_api_auth(&parts.headers).await?))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Contrôle d'accès commun aux actions, routes et pages.
|
||||
//!
|
||||
//! Le jeton de session seul ne suffit pas : il reste valide jusqu'à son
|
||||
//! expiration, même si le compte a été supprimé ou rétrogradé entre-temps. On
|
||||
//! relit donc l'utilisateur dans `users` (cache court), et le rôle en base
|
||||
//! l'emporte sur celui du jeton.
|
||||
//!
|
||||
//! Base injoignable : on se contente du jeton (mode dégradé), sans quoi le
|
||||
//! compte de secours `data/users.json` ne pourrait plus rien faire — y compris
|
||||
//! configurer la base.
|
||||
|
||||
use super::session::{from_headers, Claims};
|
||||
use crate::error::AppError;
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use parking_lot::Mutex;
|
||||
use serde::Serialize;
|
||||
use sqlx::Row;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const MESSAGE_SESSION: &str = "Session expirée : reconnectez-vous.";
|
||||
pub const MESSAGE_ADMIN: &str = "Action réservée aux administrateurs.";
|
||||
|
||||
const CACHE_TTL: Duration = Duration::from_secs(60);
|
||||
const PAUSE_BASE_INJOIGNABLE: Duration = Duration::from_secs(15);
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UtilisateurCourant {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
impl UtilisateurCourant {
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.role == "admin"
|
||||
}
|
||||
/// Identifiant numérique (`users.id`), `None` pour le compte de secours.
|
||||
pub fn id_numerique(&self) -> Option<i32> {
|
||||
self.id.parse::<i32>().ok().filter(|v| *v > 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Fiche {
|
||||
exists: bool,
|
||||
role: String,
|
||||
username: String,
|
||||
at: Instant,
|
||||
}
|
||||
|
||||
struct CacheState {
|
||||
fiches: HashMap<String, Fiche>,
|
||||
injoignable_depuis: Option<Instant>,
|
||||
}
|
||||
|
||||
static CACHE: LazyLock<Mutex<CacheState>> =
|
||||
LazyLock::new(|| Mutex::new(CacheState { fiches: HashMap::new(), injoignable_depuis: None }));
|
||||
|
||||
/// Oublie la fiche d'un utilisateur (après modification de son compte).
|
||||
pub fn invalider(id: &str) {
|
||||
CACHE.lock().fiches.remove(id);
|
||||
}
|
||||
|
||||
/// Fiche de l'utilisateur en base, ou `None` si la base n'a pas pu répondre.
|
||||
async fn lire_fiche(id: &str) -> Option<Fiche> {
|
||||
{
|
||||
let c = CACHE.lock();
|
||||
if let Some(f) = c.fiches.get(id) {
|
||||
if f.at.elapsed() < CACHE_TTL {
|
||||
return Some(f.clone());
|
||||
}
|
||||
}
|
||||
if c.injoignable_depuis.map(|t| t.elapsed() < PAUSE_BASE_INJOIGNABLE).unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let id_num: i32 = if id.chars().all(|c| c.is_ascii_digit()) && !id.is_empty() {
|
||||
id.parse::<i32>().unwrap_or(-1)
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
let res = async {
|
||||
let pool = crate::db::pool().map_err(|e| anyhow::anyhow!(e.message))?;
|
||||
let row = sqlx::query("SELECT username, role FROM users WHERE id = $1 LIMIT 1")
|
||||
.bind(id_num)
|
||||
.fetch_optional(&pool)
|
||||
.await?;
|
||||
anyhow::Ok(row)
|
||||
}
|
||||
.await;
|
||||
match res {
|
||||
Ok(row) => {
|
||||
let fiche = match row {
|
||||
Some(r) => Fiche {
|
||||
exists: true,
|
||||
username: r.try_get("username").unwrap_or_default(),
|
||||
role: r.try_get("role").unwrap_or_default(),
|
||||
at: Instant::now(),
|
||||
},
|
||||
None => Fiche { exists: false, username: String::new(), role: String::new(), at: Instant::now() },
|
||||
};
|
||||
CACHE.lock().fiches.insert(id.to_string(), fiche.clone());
|
||||
Some(fiche)
|
||||
}
|
||||
Err(e) => {
|
||||
CACHE.lock().injoignable_depuis = Some(Instant::now());
|
||||
tracing::warn!("[authz] Base injoignable, contrôle sur le seul jeton de session : {}", crate::error::short(&e, 200));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Utilisateur connecté, relu en base. 401 sinon.
|
||||
pub async fn require_session(token: Option<&Claims>) -> Result<UtilisateurCourant, AppError> {
|
||||
let jeton = token.ok_or_else(|| AppError::unauthorized(MESSAGE_SESSION))?;
|
||||
match lire_fiche(&jeton.id).await {
|
||||
None => Ok(UtilisateurCourant { id: jeton.id.clone(), username: jeton.name.clone(), role: jeton.role.clone() }),
|
||||
Some(f) if !f.exists => Err(AppError::unauthorized(MESSAGE_SESSION)),
|
||||
Some(f) => Ok(UtilisateurCourant { id: jeton.id.clone(), username: f.username, role: f.role }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Administrateur connecté. 401 ou 403 sinon.
|
||||
pub async fn require_admin(token: Option<&Claims>) -> Result<UtilisateurCourant, AppError> {
|
||||
let u = require_session(token).await?;
|
||||
if !u.is_admin() {
|
||||
return Err(AppError::forbidden(MESSAGE_ADMIN));
|
||||
}
|
||||
Ok(u)
|
||||
}
|
||||
|
||||
/// Extracteur : utilisateur connecté (sinon 401 JSON `{ error }`).
|
||||
pub struct SessionUser(pub UtilisateurCourant);
|
||||
|
||||
impl<S: Send + Sync> FromRequestParts<S> for SessionUser {
|
||||
type Rejection = AppError;
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let claims = from_headers(&parts.headers);
|
||||
Ok(SessionUser(require_session(claims.as_ref()).await?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracteur : administrateur connecté (sinon 401/403 JSON `{ error }`).
|
||||
pub struct AdminUser(pub UtilisateurCourant);
|
||||
|
||||
impl<S: Send + Sync> FromRequestParts<S> for AdminUser {
|
||||
type Rejection = AppError;
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let claims = from_headers(&parts.headers);
|
||||
Ok(AdminUser(require_admin(claims.as_ref()).await?))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Comptes de secours `data/users.json`, utilisés seulement quand la base est
|
||||
//! injoignable (première installation, base pas encore configurée).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserJson {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
fn path() -> PathBuf {
|
||||
crate::config::data_dir().join("users.json")
|
||||
}
|
||||
|
||||
fn default_admin() -> UserJson {
|
||||
UserJson {
|
||||
id: "0".into(),
|
||||
username: "admin".into(),
|
||||
password_hash: super::password::hash_password("admin"),
|
||||
role: "admin".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Comptes de secours (créés avec admin/admin si le fichier n'existe pas).
|
||||
pub fn get_fallback_users() -> Vec<UserJson> {
|
||||
let p = path();
|
||||
if !p.exists() {
|
||||
let admin = default_admin();
|
||||
save_fallback_users(&[admin.clone()]);
|
||||
return vec![admin];
|
||||
}
|
||||
match std::fs::read_to_string(&p).ok().and_then(|s| serde_json::from_str(&s).ok()) {
|
||||
Some(users) => users,
|
||||
None => {
|
||||
tracing::error!("[AUTH] Error reading users.json");
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_fallback_users(users: &[UserJson]) {
|
||||
let p = path();
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
if let Err(e) = std::fs::write(&p, serde_json::to_string_pretty(users).unwrap_or_default()) {
|
||||
tracing::error!("[AUTH] Error writing users.json: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Base injoignable : le fichier de secours doit contenir au moins un administrateur.
|
||||
pub fn assurer_admin_de_secours() {
|
||||
if get_fallback_users().is_empty() {
|
||||
tracing::info!("[AUTH] Initializing local JSON fallback with admin/admin...");
|
||||
save_fallback_users(&[default_admin()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Authentification : comptes, sessions, clés d'API.
|
||||
|
||||
pub mod api_key;
|
||||
pub mod authz;
|
||||
pub mod fallback;
|
||||
pub mod password;
|
||||
pub mod session;
|
||||
|
||||
pub use api_key::{ApiAuth, AuthContext};
|
||||
pub use authz::{require_admin, require_session, AdminUser, SessionUser, UtilisateurCourant};
|
||||
pub use session::{Claims, Token};
|
||||
|
||||
use axum::http::{header, HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sqlx::Row;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Amorçage du compte admin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `true` une fois l'amorçage réussi pour ce processus.
|
||||
static AMORCE: Mutex<bool> = Mutex::const_new(false);
|
||||
|
||||
/// Prépare les comptes au premier démarrage, une seule fois par processus.
|
||||
///
|
||||
/// - Base joignable : crée la table `users` si besoin, puis admin/admin
|
||||
/// **uniquement si la table est vide**.
|
||||
/// - Base injoignable : s'assure que le fichier de secours contient un
|
||||
/// administrateur ; on retentera la base à la prochaine connexion.
|
||||
pub async fn ensure_admin_exists() -> bool {
|
||||
let mut done = AMORCE.lock().await;
|
||||
if *done {
|
||||
return false;
|
||||
}
|
||||
match amorcer().await {
|
||||
Ok(cree) => {
|
||||
*done = true;
|
||||
cree
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[AUTH] Database seeding failed (expected if DB not configured yet): {e:#}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn amorcer() -> anyhow::Result<bool> {
|
||||
let pool = match crate::db::pool() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
fallback::assurer_admin_de_secours();
|
||||
anyhow::bail!(e.message);
|
||||
}
|
||||
};
|
||||
if let Err(e) = sqlx::query("SELECT 1").execute(&pool).await {
|
||||
fallback::assurer_admin_de_secours();
|
||||
return Err(e.into());
|
||||
}
|
||||
sqlx::raw_sql(
|
||||
r#"CREATE TABLE IF NOT EXISTS "users" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
let nb: i64 = sqlx::query_scalar("SELECT count(*) FROM users").fetch_one(&pool).await?;
|
||||
if nb > 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
tracing::info!("[AUTH] Table users vide : création du compte admin/admin.");
|
||||
let res = sqlx::query("INSERT INTO users (username, password_hash, role) VALUES ('admin', $1, 'admin') ON CONFLICT DO NOTHING")
|
||||
.bind(password::hash_password("admin"))
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connexion / déconnexion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct CompteTrouve {
|
||||
id: String,
|
||||
username: String,
|
||||
password_hash: String,
|
||||
role: String,
|
||||
}
|
||||
|
||||
/// Vérifie les identifiants. `None` si refusés.
|
||||
async fn authorize(username: &str, password: &str) -> Option<CompteTrouve> {
|
||||
ensure_admin_exists().await;
|
||||
if username.is_empty() || password.is_empty() {
|
||||
return None;
|
||||
}
|
||||
tracing::info!("[AUTH] Checking user \"{username}\"...");
|
||||
|
||||
let db_lookup = async {
|
||||
let pool = crate::db::pool().map_err(|e| anyhow::anyhow!(e.message))?;
|
||||
let row = sqlx::query("SELECT id, username, password_hash, role FROM users WHERE username = $1")
|
||||
.bind(username)
|
||||
.fetch_optional(&pool)
|
||||
.await?;
|
||||
anyhow::Ok(row.map(|r| CompteTrouve {
|
||||
id: r.try_get::<i32, _>("id").unwrap_or_default().to_string(),
|
||||
username: r.try_get("username").unwrap_or_default(),
|
||||
password_hash: r.try_get("password_hash").unwrap_or_default(),
|
||||
role: r.try_get("role").unwrap_or_else(|_| "user".into()),
|
||||
}))
|
||||
};
|
||||
|
||||
let user = match db_lookup.await {
|
||||
Ok(u) => {
|
||||
tracing::info!("[AUTH] DB check: User found: {}", u.is_some());
|
||||
u
|
||||
}
|
||||
Err(_) => {
|
||||
// Base injoignable uniquement : un compte absent de la base ne doit
|
||||
// pas pouvoir se connecter avec le fichier de secours.
|
||||
tracing::warn!("[AUTH] DB unreachable, falling back to JSON.");
|
||||
fallback::get_fallback_users().into_iter().find(|u| u.username == username).map(|u| CompteTrouve {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
password_hash: u.password_hash,
|
||||
role: u.role,
|
||||
})
|
||||
}
|
||||
}?;
|
||||
|
||||
let ok = password::verify_password(password, &user.password_hash);
|
||||
tracing::info!("[AUTH] Password valid for \"{username}\": {ok}");
|
||||
ok.then_some(user)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginBody {
|
||||
#[serde(default)]
|
||||
username: String,
|
||||
#[serde(default)]
|
||||
password: String,
|
||||
}
|
||||
|
||||
async fn login(headers: HeaderMap, Json(body): Json<LoginBody>) -> Response {
|
||||
let Some(user) = authorize(body.username.trim(), &body.password).await else {
|
||||
return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "Utilisateur ou mot de passe incorrect." }))).into_response();
|
||||
};
|
||||
let claims = Claims {
|
||||
id: user.id.clone(),
|
||||
name: user.username.clone(),
|
||||
role: user.role.clone(),
|
||||
exp: chrono::Utc::now().timestamp() + session::MAX_AGE_SECS,
|
||||
};
|
||||
(
|
||||
[(header::SET_COOKIE, session::set_cookie(&claims, &headers)), (header::CACHE_CONTROL, "no-store".into())],
|
||||
Json(json!({ "ok": true, "user": { "id": user.id, "name": user.username, "role": user.role } })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn logout() -> Response {
|
||||
([(header::SET_COOKIE, session::clear_cookie())], Json(json!({ "ok": true }))).into_response()
|
||||
}
|
||||
|
||||
/// Session courante pour l'interface (`useSession`). Relue en base : un compte
|
||||
/// supprimé n'a plus de session.
|
||||
async fn current_session(Token(token): Token) -> Response {
|
||||
let user = match token.as_ref() {
|
||||
Some(_) => authz::require_session(token.as_ref()).await.ok(),
|
||||
None => None,
|
||||
};
|
||||
let body = match user {
|
||||
Some(u) => json!({ "user": { "id": u.id, "name": u.username, "role": u.role } }),
|
||||
None => json!({ "user": null }),
|
||||
};
|
||||
([(header::CACHE_CONTROL, "no-store")], Json(body)).into_response()
|
||||
}
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/api/auth/login", post(login))
|
||||
.route("/api/auth/logout", post(logout))
|
||||
.route("/api/auth/session", get(current_session))
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Mots de passe : scrypt au format `sel:hachage` (hex), compatible avec les
|
||||
//! comptes créés par l'ancienne version Node (`scryptSync(pwd, sel, 64)`,
|
||||
//! N=16384, r=8, p=1, le sel hexadécimal étant utilisé tel quel comme octets).
|
||||
|
||||
use rand::RngCore;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
fn derive(password: &str, salt: &str) -> Option<[u8; 64]> {
|
||||
let params = scrypt::Params::new(14, 8, 1, 64).ok()?;
|
||||
let mut out = [0u8; 64];
|
||||
scrypt::scrypt(password.as_bytes(), salt.as_bytes(), ¶ms, &mut out).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn hash_password(password: &str) -> String {
|
||||
let mut salt = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut salt);
|
||||
let salt = hex::encode(salt);
|
||||
let hash = derive(password, &salt).map(hex::encode).unwrap_or_default();
|
||||
format!("{salt}:{hash}")
|
||||
}
|
||||
|
||||
pub fn verify_password(password: &str, stored: &str) -> bool {
|
||||
let Some((salt, hash)) = stored.split_once(':') else { return false };
|
||||
if salt.is_empty() || hash.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Ok(expected) = hex::decode(hash) else { return false };
|
||||
let Some(actual) = derive(password, salt) else { return false };
|
||||
expected.len() == actual.len() && bool::from(actual.as_slice().ct_eq(expected.as_slice()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
let h = hash_password("admin");
|
||||
assert!(verify_password("admin", &h));
|
||||
assert!(!verify_password("nope", &h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_node() {
|
||||
// require("crypto").scryptSync("admin", "00112233445566778899aabbccddeeff", 64).toString("hex")
|
||||
let stored = "00112233445566778899aabbccddeeff:".to_string()
|
||||
+ &hex::encode(derive("admin", "00112233445566778899aabbccddeeff").unwrap());
|
||||
assert!(verify_password("admin", &stored));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod node_compat {
|
||||
#[test]
|
||||
fn hash_from_node() {
|
||||
assert!(super::verify_password("s3cret", "abcdef0123456789abcdef0123456789:8e07d3f906fe581051e47f776fd3fb6307401dc4702369a6db2b7fbb892eb23571af616a59cf13a162cfd584035381c062b7d17fd4ca0d82a2cdf0e1ce2330aa"));
|
||||
assert!(!super::verify_password("s3cre", "abcdef0123456789abcdef0123456789:8e07d3f906fe581051e47f776fd3fb6307401dc4702369a6db2b7fbb892eb23571af616a59cf13a162cfd584035381c062b7d17fd4ca0d82a2cdf0e1ce2330aa"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Jeton de session signé (cookie HttpOnly), remplaçant le JWT NextAuth.
|
||||
//!
|
||||
//! Contenu : identifiant, nom et rôle de l'utilisateur + expiration, encodés en
|
||||
//! base64url et signés HMAC-SHA256 avec `AUTH_SECRET` (ou un secret aléatoire
|
||||
//! conservé dans `data/.auth-secret`).
|
||||
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use axum::http::HeaderMap;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::convert::Infallible;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub const COOKIE_NAME: &str = "cf_session";
|
||||
/// Durée de vie d'une session : 30 jours (comme NextAuth).
|
||||
pub const MAX_AGE_SECS: i64 = 30 * 24 * 3600;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
/// Identifiant (`users.id`, ou « 0 » pour le compte de secours).
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
/// Expiration (secondes Unix).
|
||||
pub exp: i64,
|
||||
}
|
||||
|
||||
static SECRET: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
||||
if let Ok(s) = std::env::var("AUTH_SECRET") {
|
||||
if !s.trim().is_empty() {
|
||||
return s.into_bytes();
|
||||
}
|
||||
}
|
||||
let path = crate::config::data_dir().join(".auth-secret");
|
||||
if let Ok(s) = std::fs::read_to_string(&path) {
|
||||
if !s.trim().is_empty() {
|
||||
return s.trim().as_bytes().to_vec();
|
||||
}
|
||||
}
|
||||
let mut buf = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut buf);
|
||||
let s = hex::encode(buf);
|
||||
let _ = std::fs::create_dir_all(crate::config::data_dir());
|
||||
if let Err(e) = std::fs::write(&path, &s) {
|
||||
tracing::warn!("[auth] secret de session non persisté ({e}) : les sessions ne survivront pas au redémarrage");
|
||||
}
|
||||
s.into_bytes()
|
||||
});
|
||||
|
||||
fn sign(payload: &str) -> String {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&SECRET).expect("hmac");
|
||||
mac.update(payload.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
pub fn encode(claims: &Claims) -> String {
|
||||
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap_or_default());
|
||||
let sig = sign(&payload);
|
||||
format!("{payload}.{sig}")
|
||||
}
|
||||
|
||||
pub fn decode(token: &str) -> Option<Claims> {
|
||||
let (payload, sig) = token.split_once('.')?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&SECRET).ok()?;
|
||||
mac.update(payload.as_bytes());
|
||||
let sig_bytes = URL_SAFE_NO_PAD.decode(sig).ok()?;
|
||||
mac.verify_slice(&sig_bytes).ok()?;
|
||||
let claims: Claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?;
|
||||
if claims.exp < chrono::Utc::now().timestamp() {
|
||||
return None;
|
||||
}
|
||||
Some(claims)
|
||||
}
|
||||
|
||||
/// Jeton lu dans l'en-tête `Cookie`, s'il est présent et valide.
|
||||
pub fn from_headers(headers: &HeaderMap) -> Option<Claims> {
|
||||
for value in headers.get_all(axum::http::header::COOKIE) {
|
||||
let Ok(s) = value.to_str() else { continue };
|
||||
for part in s.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(v) = part.strip_prefix(COOKIE_NAME).and_then(|r| r.strip_prefix('=')) {
|
||||
if let Some(c) = decode(v) {
|
||||
return Some(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_https(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|v| v.eq_ignore_ascii_case("https"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// En-tête `Set-Cookie` qui ouvre la session.
|
||||
pub fn set_cookie(claims: &Claims, req_headers: &HeaderMap) -> String {
|
||||
let secure = if is_https(req_headers) { "; Secure" } else { "" };
|
||||
format!("{COOKIE_NAME}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age={MAX_AGE_SECS}{secure}", encode(claims))
|
||||
}
|
||||
|
||||
/// En-tête `Set-Cookie` qui ferme la session.
|
||||
pub fn clear_cookie() -> String {
|
||||
format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0")
|
||||
}
|
||||
|
||||
/// Jeton de la requête (jamais de rejet : `None` si absent ou invalide).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Token(pub Option<Claims>);
|
||||
|
||||
impl<S: Send + Sync> FromRequestParts<S> for Token {
|
||||
type Rejection = Infallible;
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
Ok(Token(from_headers(&parts.headers)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Fichier de configuration `data/.db-config.json` (réglages saisis dans Paramètres).
|
||||
//!
|
||||
//! Le fichier contient des mots de passe et des clés d'API : il ne repart jamais
|
||||
//! tel quel vers le navigateur. Les clés inconnues sont conservées à l'écriture.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Répertoire des données persistantes (volume Docker `/app/data`).
|
||||
pub fn data_dir() -> PathBuf {
|
||||
std::env::var("DATA_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| std::env::current_dir().unwrap_or_default().join("data"))
|
||||
}
|
||||
|
||||
pub fn config_file() -> PathBuf {
|
||||
data_dir().join(".db-config.json")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DbConfig {
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub open_router_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub open_router_model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ai_provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub google_ai_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub google_ai_model: Option<String>,
|
||||
/// Qlik Sense — données réseau.
|
||||
#[serde(default)]
|
||||
pub qlik_host: Option<String>,
|
||||
#[serde(default)]
|
||||
pub qlik_user: Option<String>,
|
||||
#[serde(default)]
|
||||
pub qlik_password: Option<String>,
|
||||
/// API REST FF Nancy (ex. https://api.ffnancy.fr).
|
||||
#[serde(default)]
|
||||
pub ff_api_base_url: Option<String>,
|
||||
}
|
||||
|
||||
fn read_raw_sync() -> Map<String, Value> {
|
||||
std::fs::read_to_string(config_file())
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
|
||||
.and_then(|v| match v {
|
||||
Value::Object(m) => Some(m),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Lit la config existante (vide si absente ou illisible).
|
||||
pub fn read_config() -> DbConfig {
|
||||
serde_json::from_value(Value::Object(read_raw_sync())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Fusionne `patch` dans la config enregistrée : les clés absentes du patch sont
|
||||
/// conservées ; une clé passée à `null` est retirée.
|
||||
pub fn write_config(patch: Map<String, Value>) -> std::io::Result<()> {
|
||||
let mut existing = read_raw_sync();
|
||||
for (k, v) in patch {
|
||||
if v.is_null() {
|
||||
existing.remove(&k);
|
||||
} else {
|
||||
existing.insert(k, v);
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(data_dir())?;
|
||||
let text = serde_json::to_string_pretty(&Value::Object(existing)).unwrap_or_else(|_| "{}".into());
|
||||
std::fs::write(config_file(), text)
|
||||
}
|
||||
|
||||
/// Chaîne non vide, sinon `None`.
|
||||
pub fn non_empty(s: Option<String>) -> Option<String> {
|
||||
s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// URL de connexion PostgreSQL : réglage enregistré, sinon `DATABASE_URL`.
|
||||
pub fn database_url() -> Option<String> {
|
||||
non_empty(read_config().url).or_else(|| non_empty(std::env::var("DATABASE_URL").ok()))
|
||||
}
|
||||
|
||||
/// Variable d'environnement numérique bornée (équivalent de `envNumber` côté TS).
|
||||
pub fn env_number(name: &str, default: i64, min: i64, max: i64) -> i64 {
|
||||
match std::env::var(name) {
|
||||
Ok(raw) if !raw.trim().is_empty() => match raw.trim().parse::<f64>() {
|
||||
Ok(v) if v.is_finite() => (v.trunc() as i64).clamp(min, max),
|
||||
_ => default,
|
||||
},
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Pool PostgreSQL unique par processus, remplaçable à chaud.
|
||||
//!
|
||||
//! L'URL vient de `data/.db-config.json` (Paramètres), sinon de `DATABASE_URL`.
|
||||
//! Enregistrer de nouveaux réglages appelle [`refresh`] : le pool suivant est
|
||||
//! recréé à la première requête.
|
||||
//!
|
||||
//! La même base porte les tables de l'application et la recopie nocturne des
|
||||
//! tables FF Nancy (`articles`, `mvtart`, `cube_stock`, `fouident`…).
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
|
||||
use parking_lot::RwLock;
|
||||
use serde_json::{Map, Value};
|
||||
use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgRow};
|
||||
use sqlx::{Column, Executor, PgPool, Row, TypeInfo, ValueRef};
|
||||
use std::str::FromStr;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
static POOL: LazyLock<RwLock<Option<PgPool>>> = LazyLock::new(|| RwLock::new(None));
|
||||
|
||||
/// Masque le mot de passe d'une URL de connexion pour les journaux.
|
||||
pub fn mask_url(url: &str) -> String {
|
||||
let re = regex::Regex::new(r":([^@/]+)@").unwrap();
|
||||
re.replace(url, ":****@").into_owned()
|
||||
}
|
||||
|
||||
/// Options de connexion communes (pool applicatif et tests de connexion).
|
||||
pub fn connect_options(url: &str) -> Result<PgConnectOptions, sqlx::Error> {
|
||||
let mut opts = PgConnectOptions::from_str(url)?.application_name("collectflow");
|
||||
// Délai maximal par requête : opt-in (les calculs nocturnes des plus gros
|
||||
// fournisseurs peuvent être longs).
|
||||
if let Some(ms) = std::env::var("PG_STATEMENT_TIMEOUT_MS").ok().and_then(|v| v.parse::<u64>().ok()).filter(|v| *v > 0) {
|
||||
opts = opts.options([("statement_timeout", ms.to_string())]);
|
||||
}
|
||||
Ok(opts)
|
||||
}
|
||||
|
||||
fn build_pool(url: &str) -> Result<PgPool, sqlx::Error> {
|
||||
let opts = connect_options(url)?;
|
||||
Ok(PgPoolOptions::new()
|
||||
.max_connections(20)
|
||||
.idle_timeout(Duration::from_secs(30))
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
// Pas de workers parallèles PostgreSQL : ils consomment /dev/shm, trop
|
||||
// petit dans le conteneur, et font échouer les grosses agrégations.
|
||||
.after_connect(|conn, _meta| {
|
||||
Box::pin(async move {
|
||||
if let Err(e) = conn.execute("SET max_parallel_workers_per_gather = 0").await {
|
||||
tracing::error!("[DB] SET max_parallel_workers_per_gather failed: {e}");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.connect_lazy_with(opts))
|
||||
}
|
||||
|
||||
/// Pool courant, créé à la demande. Erreur 500 si aucune base n'est configurée.
|
||||
pub fn pool() -> AppResult<PgPool> {
|
||||
if let Some(p) = POOL.read().as_ref() {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
let mut guard = POOL.write();
|
||||
if let Some(p) = guard.as_ref() {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
let url = crate::config::database_url().ok_or_else(|| {
|
||||
tracing::error!("[DB] CRITICAL: No database connection string available!");
|
||||
AppError::internal("Base de données non configurée (Paramètres → Base de données).")
|
||||
})?;
|
||||
tracing::info!("[DB] Initializing connection with: {}", mask_url(&url));
|
||||
let p = build_pool(&url).map_err(|e| AppError::internal(format!("URL de base invalide : {e}")))?;
|
||||
*guard = Some(p.clone());
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Oublie le pool courant (réglages modifiés dans Paramètres).
|
||||
pub fn refresh() {
|
||||
tracing::info!("[DB] Refreshing database connection...");
|
||||
if let Some(old) = POOL.write().take() {
|
||||
tokio::spawn(async move { old.close().await });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversion générique d'une ligne en JSON
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Horodatage au format JS `toISOString()` (`2026-01-31T12:34:56.789Z`).
|
||||
pub fn iso(dt: NaiveDateTime) -> String {
|
||||
dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
|
||||
}
|
||||
|
||||
pub fn iso_utc(dt: DateTime<Utc>) -> String {
|
||||
iso(dt.naive_utc())
|
||||
}
|
||||
|
||||
/// Convertit une ligne en objet JSON, colonne par colonne, selon le type SQL.
|
||||
///
|
||||
/// Les `numeric` deviennent des nombres (le pilote Node les rendait en chaînes,
|
||||
/// que le code convertissait avec `Number()`), les horodatages des chaînes ISO.
|
||||
pub fn row_to_json(row: &PgRow) -> Value {
|
||||
let mut obj = Map::new();
|
||||
for (i, col) in row.columns().iter().enumerate() {
|
||||
obj.insert(col.name().to_string(), cell_to_json(row, i));
|
||||
}
|
||||
Value::Object(obj)
|
||||
}
|
||||
|
||||
pub fn rows_to_json(rows: &[PgRow]) -> Vec<Value> {
|
||||
rows.iter().map(row_to_json).collect()
|
||||
}
|
||||
|
||||
fn cell_to_json(row: &PgRow, i: usize) -> Value {
|
||||
let raw = match row.try_get_raw(i) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Value::Null,
|
||||
};
|
||||
if raw.is_null() {
|
||||
return Value::Null;
|
||||
}
|
||||
let ty = raw.type_info().name().to_ascii_uppercase();
|
||||
macro_rules! get {
|
||||
($t:ty) => {
|
||||
row.try_get::<$t, _>(i).ok()
|
||||
};
|
||||
}
|
||||
let num = |f: Option<f64>| f.and_then(serde_json::Number::from_f64).map(Value::Number).unwrap_or(Value::Null);
|
||||
match ty.as_str() {
|
||||
"BOOL" => get!(bool).map(Value::Bool).unwrap_or(Value::Null),
|
||||
"INT2" => get!(i16).map(|v| Value::from(v)).unwrap_or(Value::Null),
|
||||
"INT4" => get!(i32).map(|v| Value::from(v)).unwrap_or(Value::Null),
|
||||
"INT8" => get!(i64).map(|v| Value::from(v)).unwrap_or(Value::Null),
|
||||
"FLOAT4" => num(get!(f32).map(|v| v as f64)),
|
||||
"FLOAT8" => num(get!(f64)),
|
||||
"NUMERIC" => num(get!(rust_decimal::Decimal).and_then(|d| d.to_string().parse::<f64>().ok())),
|
||||
"JSON" | "JSONB" => get!(Value).unwrap_or(Value::Null),
|
||||
"TIMESTAMP" => get!(NaiveDateTime).map(|d| Value::String(iso(d))).unwrap_or(Value::Null),
|
||||
"TIMESTAMPTZ" => get!(DateTime<Utc>).map(|d| Value::String(iso_utc(d))).unwrap_or(Value::Null),
|
||||
"DATE" => get!(NaiveDate)
|
||||
.map(|d| Value::String(iso(d.and_hms_opt(0, 0, 0).unwrap())))
|
||||
.unwrap_or(Value::Null),
|
||||
"TEXT[]" | "VARCHAR[]" | "_TEXT" | "_VARCHAR" => get!(Vec<String>).map(Value::from).unwrap_or(Value::Null),
|
||||
_ => get!(String).map(Value::String).unwrap_or(Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lecture tolérante de colonnes (équivalent des `Number(r.x) || 0` du code TS)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Valeur numérique d'une colonne quel que soit son type SQL (int, numeric,
|
||||
/// float, texte numérique). `None` si NULL ou illisible.
|
||||
pub fn get_f64(row: &PgRow, col: &str) -> Option<f64> {
|
||||
let raw = row.try_get_raw(col).ok()?;
|
||||
if raw.is_null() {
|
||||
return None;
|
||||
}
|
||||
let ty = raw.type_info().name().to_ascii_uppercase();
|
||||
match ty.as_str() {
|
||||
"INT2" => row.try_get::<i16, _>(col).ok().map(|v| v as f64),
|
||||
"INT4" => row.try_get::<i32, _>(col).ok().map(|v| v as f64),
|
||||
"INT8" => row.try_get::<i64, _>(col).ok().map(|v| v as f64),
|
||||
"FLOAT4" => row.try_get::<f32, _>(col).ok().map(|v| v as f64),
|
||||
"FLOAT8" => row.try_get::<f64, _>(col).ok(),
|
||||
"NUMERIC" => row
|
||||
.try_get::<rust_decimal::Decimal, _>(col)
|
||||
.ok()
|
||||
.and_then(|d| d.to_string().parse::<f64>().ok()),
|
||||
_ => row.try_get::<String, _>(col).ok().and_then(|s| s.trim().parse::<f64>().ok()),
|
||||
}
|
||||
.filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// `Number(r.col) || 0`.
|
||||
pub fn f64_or0(row: &PgRow, col: &str) -> f64 {
|
||||
get_f64(row, col).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Valeur texte d'une colonne quel que soit son type (nombres et dates convertis).
|
||||
pub fn get_string(row: &PgRow, col: &str) -> Option<String> {
|
||||
let idx = row.columns().iter().position(|c| c.name() == col)?;
|
||||
match cell_to_json(row, idx) {
|
||||
Value::Null => None,
|
||||
Value::String(s) => Some(s),
|
||||
Value::Number(n) => {
|
||||
// 12.0 → "12", comme String(12) en JS.
|
||||
let f = n.as_f64().unwrap_or_default();
|
||||
if f.fract() == 0.0 && f.abs() < 1e15 {
|
||||
Some(format!("{}", f as i64))
|
||||
} else {
|
||||
Some(n.to_string())
|
||||
}
|
||||
}
|
||||
other => Some(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `String(r.col ?? "")`.
|
||||
pub fn string_or_empty(row: &PgRow, col: &str) -> String {
|
||||
get_string(row, col).unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Référentiels partagés : magasins et gammes (miroir de `src/lib/magasins.ts`
|
||||
//! et `src/lib/gammes.ts` côté interface).
|
||||
|
||||
pub struct Magasin {
|
||||
/// Code FF du site (clé de toutes les requêtes).
|
||||
pub code: &'static str,
|
||||
/// Nom complet.
|
||||
pub nom: &'static str,
|
||||
/// Nom court.
|
||||
pub court: &'static str,
|
||||
}
|
||||
|
||||
pub const MAGASINS: [Magasin; 2] = [
|
||||
Magasin { code: "292", nom: "Frouard (Nancy)", court: "Frouard" },
|
||||
Magasin { code: "579", nom: "Houdemont", court: "Houdemont" },
|
||||
];
|
||||
|
||||
/// Code utilisé par la Grille et l'API pour « tous nos magasins ».
|
||||
pub const CODE_TOUS_MAGASINS: &str = "TOTAL";
|
||||
pub const LIBELLE_TOUS_MAGASINS: &str = "Nos 2 magasins";
|
||||
|
||||
/// Anciens codes encore présents dans certaines réponses de l'API FF.
|
||||
fn alias(code: &str) -> &str {
|
||||
match code {
|
||||
"F01" => "292",
|
||||
"F02" => "579",
|
||||
c => c,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trouver_magasin(code: &str) -> Option<&'static Magasin> {
|
||||
let c = alias(code.trim());
|
||||
MAGASINS.iter().find(|m| m.code == c)
|
||||
}
|
||||
|
||||
/// Nom lisible d'un magasin. `TOTAL` (ou vide) → « Nos 2 magasins ».
|
||||
pub fn nom_magasin(code: Option<&str>, court: bool) -> String {
|
||||
match code {
|
||||
None | Some("") | Some(CODE_TOUS_MAGASINS) => LIBELLE_TOUS_MAGASINS.to_string(),
|
||||
Some(c) => match trouver_magasin(c) {
|
||||
Some(m) => (if court { m.court } else { m.nom }).to_string(),
|
||||
None => c.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Codes de gamme valides.
|
||||
pub const CODES_GAMME: [&str; 5] = ["A", "B", "C", "Y", "Z"];
|
||||
|
||||
pub fn est_gamme_valide(code: &str) -> bool {
|
||||
CODES_GAMME.contains(&code)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Erreurs HTTP communes.
|
||||
//!
|
||||
//! Les routes « internes » (appelées par l'interface) répondent `{ "error": "…" }`
|
||||
//! avec le statut adéquat ; l'API publique `/api/v1` a sa propre enveloppe
|
||||
//! (`crate::api_v1_response`).
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AppError {
|
||||
pub status: StatusCode,
|
||||
pub message: String,
|
||||
/// Corps JSON complet à renvoyer à la place de `{ error }`.
|
||||
pub body: Option<Value>,
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
|
||||
impl AppError {
|
||||
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
|
||||
Self { status, message: message.into(), body: None }
|
||||
}
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::BAD_REQUEST, message)
|
||||
}
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::UNAUTHORIZED, message)
|
||||
}
|
||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::FORBIDDEN, message)
|
||||
}
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::NOT_FOUND, message)
|
||||
}
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
|
||||
}
|
||||
/// Réponse au corps libre (ex. `{ success: false, error }` avec un statut 500).
|
||||
pub fn with_body(status: StatusCode, body: Value) -> Self {
|
||||
let message = body.get("error").and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
||||
Self { status, message, body: Some(body) }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AppError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{} {}", self.status, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AppError {}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let body = self.body.unwrap_or_else(|| json!({ "error": self.message }));
|
||||
(self.status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
// Une AppError enveloppée dans anyhow garde son statut.
|
||||
match e.downcast::<AppError>() {
|
||||
Ok(app) => app,
|
||||
Err(e) => {
|
||||
tracing::error!("[error] {e:#}");
|
||||
Self::internal(format!("{e:#}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for AppError {
|
||||
fn from(e: sqlx::Error) -> Self {
|
||||
tracing::error!("[db] {e}");
|
||||
Self::internal(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for AppError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
Self::bad_request(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for AppError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Self::internal(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for AppError {
|
||||
fn from(e: reqwest::Error) -> Self {
|
||||
Self::internal(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Message d'une erreur quelconque tronqué à `n` caractères (pour les journaux).
|
||||
pub fn short(e: &dyn std::fmt::Display, n: usize) -> String {
|
||||
let s = e.to_string();
|
||||
s.chars().take(n).collect()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Module `ff` — à porter.
|
||||
|
||||
use crate::rpc::Handler;
|
||||
use axum::Router;
|
||||
|
||||
/// Routes HTTP du module.
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
}
|
||||
|
||||
/// Actions (`POST /api/actions/{nom}`) du module.
|
||||
pub fn actions() -> Vec<(&'static str, Handler)> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Données de pages (`GET /api/pages/{nom}`) du module.
|
||||
pub fn pages() -> Vec<(&'static str, Handler)> {
|
||||
vec![]
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Cache mémoire des lectures « FF » (base miroir et API FF Nancy).
|
||||
//!
|
||||
//! Les tables FF ne sont recopiées qu'une fois par nuit : recalculer à chaque
|
||||
//! visite les mêmes agrégations lourdes coûtait plusieurs secondes par page.
|
||||
//! - durée de vie bornée (`FF_CACHE_TTL_S`, 30 min par défaut) ;
|
||||
//! - les appels simultanés pour la même clé partagent une seule requête ;
|
||||
//! - une erreur n'est jamais mise en cache ;
|
||||
//! - [`invalidate`] vide tout (fin de synchro nocturne).
|
||||
|
||||
use futures::future::{BoxFuture, FutureExt, Shared};
|
||||
use parking_lot::Mutex;
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const MAX_ENTRIES: usize = 100;
|
||||
|
||||
type AnyArc = Arc<dyn Any + Send + Sync>;
|
||||
type Pending = Shared<BoxFuture<'static, Result<AnyArc, Arc<String>>>>;
|
||||
|
||||
enum Entry {
|
||||
Ready { value: AnyArc, expires_at: Instant, inserted: u64 },
|
||||
Pending(Pending),
|
||||
}
|
||||
|
||||
struct Store {
|
||||
map: HashMap<String, Entry>,
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
static STORE: LazyLock<Mutex<Store>> = LazyLock::new(|| Mutex::new(Store { map: HashMap::new(), seq: 0 }));
|
||||
static GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub fn default_ttl() -> Duration {
|
||||
let s = std::env::var("FF_CACHE_TTL_S").ok().and_then(|v| v.parse::<u64>().ok()).filter(|v| *v > 0).unwrap_or(30 * 60);
|
||||
Duration::from_secs(s)
|
||||
}
|
||||
|
||||
/// Options d'une lecture mise en cache.
|
||||
pub struct CacheOpts<T> {
|
||||
/// Durée de vie propre à cette clé.
|
||||
pub ttl: Option<Duration>,
|
||||
/// Ne garder le résultat que s'il passe ce test (ex. liste non vide : une
|
||||
/// lecture qui avale ses erreurs ne doit pas figer une panne passagère).
|
||||
pub cache_if: Option<fn(&T) -> bool>,
|
||||
}
|
||||
|
||||
impl<T> Default for CacheOpts<T> {
|
||||
fn default() -> Self {
|
||||
Self { ttl: None, cache_if: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Lecture mise en cache sous `key`.
|
||||
pub async fn cached<T, F, Fut>(key: &str, opts: CacheOpts<T>, load: F) -> anyhow::Result<T>
|
||||
where
|
||||
T: Clone + Send + Sync + 'static,
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = anyhow::Result<T>> + Send + 'static,
|
||||
{
|
||||
let pending = {
|
||||
let mut 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>() {
|
||||
return Ok(v.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
Some(Entry::Pending(p)) => Some(p.clone()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
let pending = match pending {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let generation = GENERATION.load(Ordering::SeqCst);
|
||||
let key_owned = key.to_string();
|
||||
let ttl = opts.ttl.unwrap_or_else(default_ttl);
|
||||
let cache_if = opts.cache_if;
|
||||
let fut = load();
|
||||
let shared: Pending = async move {
|
||||
let res = fut.await;
|
||||
let mut store = STORE.lock();
|
||||
match res {
|
||||
Ok(value) => {
|
||||
let keep = cache_if.map(|f| f(&value)).unwrap_or(true);
|
||||
let any: AnyArc = Arc::new(value);
|
||||
if generation != GENERATION.load(Ordering::SeqCst) {
|
||||
// Invalidation pendant le chargement : résultat rendu mais pas gardé.
|
||||
} else if keep {
|
||||
store.seq += 1;
|
||||
let inserted = store.seq;
|
||||
store.map.insert(
|
||||
key_owned,
|
||||
Entry::Ready { value: any.clone(), expires_at: Instant::now() + ttl, inserted },
|
||||
);
|
||||
evict(&mut store);
|
||||
} else {
|
||||
store.map.remove(&key_owned);
|
||||
}
|
||||
Ok(any)
|
||||
}
|
||||
Err(e) => {
|
||||
if generation == GENERATION.load(Ordering::SeqCst) {
|
||||
store.map.remove(&key_owned);
|
||||
}
|
||||
Err(Arc::new(format!("{e:#}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
.shared();
|
||||
STORE.lock().map.insert(key.to_string(), Entry::Pending(shared.clone()));
|
||||
shared
|
||||
}
|
||||
};
|
||||
|
||||
match pending.await {
|
||||
Ok(any) => any
|
||||
.downcast_ref::<T>()
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("ff_cache: type inattendu pour la clé {key}")),
|
||||
Err(msg) => Err(anyhow::anyhow!("{msg}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn evict(store: &mut Store) {
|
||||
while store.map.len() > MAX_ENTRIES {
|
||||
let oldest = store
|
||||
.map
|
||||
.iter()
|
||||
.filter_map(|(k, e)| match e {
|
||||
Entry::Ready { inserted, .. } => Some((k.clone(), *inserted)),
|
||||
_ => None,
|
||||
})
|
||||
.min_by_key(|(_, i)| *i)
|
||||
.map(|(k, _)| k);
|
||||
match oldest {
|
||||
Some(k) => {
|
||||
store.map.remove(&k);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vide tout le cache FF (fin de synchro nocturne, données fraîches).
|
||||
pub fn invalidate() {
|
||||
GENERATION.fetch_add(1, Ordering::SeqCst);
|
||||
STORE.lock().map.clear();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Module `grid` — à porter.
|
||||
|
||||
use crate::rpc::Handler;
|
||||
use axum::Router;
|
||||
|
||||
/// Routes HTTP du module.
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
}
|
||||
|
||||
/// Actions (`POST /api/actions/{nom}`) du module.
|
||||
pub fn actions() -> Vec<(&'static str, Handler)> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Données de pages (`GET /api/pages/{nom}`) du module.
|
||||
pub fn pages() -> Vec<(&'static str, Handler)> {
|
||||
vec![]
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Capture des journaux serveur d'un job, pour téléchargement.
|
||||
//!
|
||||
//! Les extractions Qlik produisent des milliers de lignes ; la sortie standard
|
||||
//! du conteneur les tronque. Tant qu'au moins une capture est ouverte, chaque
|
||||
//! ligne journalisée (par n'importe quel code du processus) y est recopiée, puis
|
||||
//! écrite sur disque au fil de l'eau (toutes les 2 s au plus).
|
||||
//!
|
||||
//! ⚠️ Deux jobs simultanés partagent les journaux du processus : chaque capture
|
||||
//! ouverte reçoit TOUTES les lignes de la période.
|
||||
|
||||
use chrono::Utc;
|
||||
use parking_lot::Mutex;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Write as _;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::{Instant, SystemTime};
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing::{Event, Subscriber};
|
||||
use tracing_subscriber::layer::Context;
|
||||
use tracing_subscriber::Layer;
|
||||
|
||||
const MAX_LIGNES: usize = 200_000;
|
||||
const MAX_LONGUEUR_LIGNE: usize = 8_000;
|
||||
const MAX_FICHIERS: usize = 20;
|
||||
const FLUSH_MS: u128 = 2_000;
|
||||
|
||||
struct Capture {
|
||||
lignes: Vec<String>,
|
||||
tronque: bool,
|
||||
debut: Instant,
|
||||
dernier_flush: Option<Instant>,
|
||||
ecrites: usize,
|
||||
}
|
||||
|
||||
static CAPTURES: LazyLock<Mutex<HashMap<String, Capture>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
fn log_dir() -> PathBuf {
|
||||
std::env::temp_dir().join("collectflow-logs")
|
||||
}
|
||||
|
||||
fn nom_sur(id: &str) -> String {
|
||||
id.chars().map(|c| if c.is_ascii_alphanumeric() || "_.-".contains(c) { c } else { '_' }).collect()
|
||||
}
|
||||
|
||||
fn fichier_pour(id: &str) -> PathBuf {
|
||||
log_dir().join(format!("{}.log", nom_sur(id)))
|
||||
}
|
||||
|
||||
fn horodatage() -> String {
|
||||
Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
|
||||
}
|
||||
|
||||
/// Écrit sur disque les lignes pas encore écrites (synchrone, appelé sous verrou).
|
||||
fn flush(id: &str, c: &mut Capture) {
|
||||
let _ = std::fs::create_dir_all(log_dir());
|
||||
let total = c.lignes.len();
|
||||
if c.ecrites == 0 || total > c.ecrites {
|
||||
let nouvelles = &c.lignes[c.ecrites..total];
|
||||
let mut texte = nouvelles.join("\n");
|
||||
texte.push('\n');
|
||||
let res = if c.ecrites == 0 {
|
||||
std::fs::write(fichier_pour(id), texte)
|
||||
} else {
|
||||
std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(fichier_pour(id))
|
||||
.and_then(|mut f| f.write_all(texte.as_bytes()))
|
||||
};
|
||||
if res.is_ok() {
|
||||
c.ecrites = total;
|
||||
}
|
||||
}
|
||||
c.dernier_flush = Some(Instant::now());
|
||||
}
|
||||
|
||||
/// Ajoute une ligne à toutes les captures ouvertes.
|
||||
pub fn ajouter(ligne: &str) {
|
||||
let mut captures = CAPTURES.lock();
|
||||
if captures.is_empty() {
|
||||
return;
|
||||
}
|
||||
let texte = if ligne.len() > MAX_LONGUEUR_LIGNE {
|
||||
let mut cut = MAX_LONGUEUR_LIGNE;
|
||||
while !ligne.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
format!("{} …[ligne tronquée]", &ligne[..cut])
|
||||
} else {
|
||||
ligne.to_string()
|
||||
};
|
||||
let prefixe = format!("[{}] ", horodatage());
|
||||
for (id, c) in captures.iter_mut() {
|
||||
if c.lignes.len() >= MAX_LIGNES {
|
||||
if !c.tronque {
|
||||
c.tronque = true;
|
||||
c.lignes.push(format!("{prefixe}[log-capture] limite de {MAX_LIGNES} lignes atteinte — suite non enregistrée"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
c.lignes.push(format!("{prefixe}{texte}"));
|
||||
if c.dernier_flush.map(|t| t.elapsed().as_millis() >= FLUSH_MS).unwrap_or(true) {
|
||||
flush(id, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn purger() {
|
||||
let Ok(rd) = std::fs::read_dir(log_dir()) else { return };
|
||||
let mut fichiers: Vec<(PathBuf, SystemTime)> = rd
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().ends_with(".log"))
|
||||
.map(|e| (e.path(), e.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH)))
|
||||
.collect();
|
||||
if fichiers.len() <= MAX_FICHIERS {
|
||||
return;
|
||||
}
|
||||
fichiers.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
for (p, _) in fichiers.into_iter().skip(MAX_FICHIERS) {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ouvre une capture : toutes les lignes journalisées à partir de maintenant y
|
||||
/// sont recopiées jusqu'à [`stop_capture`].
|
||||
pub fn start_capture(id: &str, entete: Option<&str>) {
|
||||
let mut c = Capture { lignes: Vec::new(), tronque: false, debut: Instant::now(), dernier_flush: None, ecrites: 0 };
|
||||
c.lignes.push(format!("[{}] [log-capture] début de capture {id}", horodatage()));
|
||||
if let Some(e) = entete {
|
||||
c.lignes.push(format!("[{}] [log-capture] {e}", horodatage()));
|
||||
}
|
||||
flush(id, &mut c);
|
||||
CAPTURES.lock().insert(id.to_string(), c);
|
||||
purger();
|
||||
}
|
||||
|
||||
/// Ferme la capture et écrit son état final sur disque.
|
||||
pub fn stop_capture(id: &str, pied: Option<&str>) {
|
||||
let Some(mut c) = CAPTURES.lock().remove(id) else { return };
|
||||
let duree = c.debut.elapsed().as_secs();
|
||||
let n = c.lignes.len();
|
||||
c.lignes.push(format!("[{}] [log-capture] fin de capture {id} — {n} lignes, {duree} s", horodatage()));
|
||||
if let Some(p) = pied {
|
||||
c.lignes.push(format!("[{}] [log-capture] {p}", horodatage()));
|
||||
}
|
||||
flush(id, &mut c);
|
||||
}
|
||||
|
||||
/// Contenu d'un journal : la capture en cours, sinon le fichier sur disque.
|
||||
pub fn read_capture(id: &str) -> Option<String> {
|
||||
if let Some(c) = CAPTURES.lock().get(id) {
|
||||
return Some(c.lignes.join("\n") + "\n");
|
||||
}
|
||||
std::fs::read_to_string(fichier_pour(id)).ok()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CaptureInfo {
|
||||
pub id: String,
|
||||
pub octets: u64,
|
||||
pub modifie_le: String,
|
||||
pub en_cours: bool,
|
||||
}
|
||||
|
||||
/// Journaux disponibles, du plus récent au plus ancien.
|
||||
pub fn list_captures() -> Vec<CaptureInfo> {
|
||||
let en_cours: Vec<String> = CAPTURES.lock().keys().cloned().collect();
|
||||
let mut out = Vec::new();
|
||||
if let Ok(rd) = std::fs::read_dir(log_dir()) {
|
||||
for e in rd.filter_map(|e| e.ok()) {
|
||||
let nom = e.file_name().to_string_lossy().to_string();
|
||||
let Some(id) = nom.strip_suffix(".log") else { continue };
|
||||
let Ok(meta) = e.metadata() else { continue };
|
||||
let modifie: chrono::DateTime<Utc> = meta.modified().map(Into::into).unwrap_or_else(|_| Utc::now());
|
||||
out.push(CaptureInfo {
|
||||
id: id.to_string(),
|
||||
octets: meta.len(),
|
||||
modifie_le: modifie.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
|
||||
en_cours: en_cours.iter().any(|x| nom_sur(x) == id),
|
||||
});
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.modifie_le.cmp(&a.modifie_le));
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Couche `tracing` qui alimente les captures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct CaptureLayer;
|
||||
|
||||
struct MessageVisitor(String);
|
||||
|
||||
impl Visit for MessageVisitor {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
if field.name() == "message" {
|
||||
let _ = write!(self.0, "{value:?}");
|
||||
} else {
|
||||
let _ = write!(self.0, " {}={value:?}", field.name());
|
||||
}
|
||||
}
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
if field.name() == "message" {
|
||||
self.0.push_str(value);
|
||||
} else {
|
||||
let _ = write!(self.0, " {}={value}", field.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Subscriber> Layer<S> for CaptureLayer {
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
// Aucune capture ouverte (le cas courant) : on ne formate rien.
|
||||
if CAPTURES.lock().is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut v = MessageVisitor(String::new());
|
||||
event.record(&mut v);
|
||||
ajouter(&v.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
#![allow(dead_code)]
|
||||
//! CollectFlow — serveur.
|
||||
//!
|
||||
//! Un binaire unique : API JSON (interface + `/api/v1` publique), accès à la
|
||||
//! base PostgreSQL (tables de l'application et recopie FF Nancy), extraction
|
||||
//! Qlik Sense, synchronisation nocturne, et service de l'interface compilée.
|
||||
|
||||
mod admin;
|
||||
mod api_v1_response;
|
||||
mod auth;
|
||||
mod config;
|
||||
mod db;
|
||||
mod domain;
|
||||
mod error;
|
||||
mod ff;
|
||||
mod ff_cache;
|
||||
mod grid;
|
||||
mod logcapture;
|
||||
mod migrations;
|
||||
mod produits;
|
||||
mod qlik;
|
||||
mod routes;
|
||||
mod rpc;
|
||||
mod spa;
|
||||
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::Router;
|
||||
use rpc::Handler;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::{EnvFilter, Layer};
|
||||
|
||||
/// Toutes les actions déclarées par les modules.
|
||||
pub fn modules_actions() -> Vec<(&'static str, Handler)> {
|
||||
[ff::actions(), qlik::actions(), grid::actions(), produits::actions(), admin::actions(), routes::actions()].concat()
|
||||
}
|
||||
|
||||
/// Toutes les pages déclarées par les modules.
|
||||
pub fn modules_pages() -> Vec<(&'static str, Handler)> {
|
||||
[ff::pages(), qlik::pages(), grid::pages(), produits::pages(), admin::pages(), routes::pages()].concat()
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,chromiumoxide=warn,tungstenite=warn,hyper=warn"));
|
||||
let fmt = tracing_subscriber::fmt::layer().with_target(false).with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout())).with_filter(filter);
|
||||
tracing_subscriber::registry().with(fmt).with(logcapture::CaptureLayer).init();
|
||||
}
|
||||
|
||||
fn app() -> Router {
|
||||
Router::new()
|
||||
.merge(auth::router())
|
||||
.merge(rpc::router())
|
||||
.merge(ff::router())
|
||||
.merge(qlik::router())
|
||||
.merge(grid::router())
|
||||
.merge(produits::router())
|
||||
.merge(admin::router())
|
||||
.merge(routes::router())
|
||||
.merge(spa::router())
|
||||
// Les instantanés de la Grille peuvent peser plusieurs Mo.
|
||||
.layer(DefaultBodyLimit::max(64 * 1024 * 1024))
|
||||
.layer(CompressionLayer::new())
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||
s.recv().await;
|
||||
}
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
tracing::info!("Arrêt demandé.");
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
init_tracing();
|
||||
tracing::info!("CollectFlow {} — démarrage", env!("CARGO_PKG_VERSION"));
|
||||
let _ = std::fs::create_dir_all(config::data_dir());
|
||||
|
||||
migrations::run().await;
|
||||
tokio::spawn(async {
|
||||
auth::ensure_admin_exists().await;
|
||||
});
|
||||
admin::start_background_tasks();
|
||||
|
||||
let port: u16 = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(5643);
|
||||
let host = std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()).unwrap_or_else(|| "0.0.0.0".into());
|
||||
let addr = format!("{host}:{port}");
|
||||
let listener = match tokio::net::TcpListener::bind(&addr).await {
|
||||
Ok(l) => l,
|
||||
// HOSTNAME est parfois le nom du conteneur : on retombe sur toutes les interfaces.
|
||||
Err(_) => tokio::net::TcpListener::bind(("0.0.0.0", port)).await?,
|
||||
};
|
||||
tracing::info!("Écoute sur http://{}", listener.local_addr()?);
|
||||
axum::serve(listener, app()).with_graceful_shutdown(shutdown_signal()).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Schéma de la base, joué à chaque démarrage (idempotent).
|
||||
|
||||
const INIT_SQL: &str = include_str!("../sql/init.sql");
|
||||
|
||||
pub async fn run() {
|
||||
tracing::info!("[DB Init] Starting database migration & initialization...");
|
||||
let pool = match crate::db::pool() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
tracing::warn!("[DB Init] No DATABASE_URL found in env or config. Skipping.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
match sqlx::raw_sql(INIT_SQL).execute(&pool).await {
|
||||
Ok(_) => tracing::info!("[DB Init] Initialization successful."),
|
||||
// L'application reste utilisable (comptes de secours, Paramètres).
|
||||
Err(e) => tracing::error!("[DB Init] Initialization failed: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Module `produits` — à porter.
|
||||
|
||||
use crate::rpc::Handler;
|
||||
use axum::Router;
|
||||
|
||||
/// Routes HTTP du module.
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
}
|
||||
|
||||
/// Actions (`POST /api/actions/{nom}`) du module.
|
||||
pub fn actions() -> Vec<(&'static str, Handler)> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Données de pages (`GET /api/pages/{nom}`) du module.
|
||||
pub fn pages() -> Vec<(&'static str, Handler)> {
|
||||
vec![]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Module `qlik` — à porter.
|
||||
|
||||
use crate::rpc::Handler;
|
||||
use axum::Router;
|
||||
|
||||
/// Routes HTTP du module.
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
}
|
||||
|
||||
/// Actions (`POST /api/actions/{nom}`) du module.
|
||||
pub fn actions() -> Vec<(&'static str, Handler)> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Données de pages (`GET /api/pages/{nom}`) du module.
|
||||
pub fn pages() -> Vec<(&'static str, Handler)> {
|
||||
vec![]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Module `routes` — à porter.
|
||||
|
||||
use crate::rpc::Handler;
|
||||
use axum::Router;
|
||||
|
||||
/// Routes HTTP du module.
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
}
|
||||
|
||||
/// Actions (`POST /api/actions/{nom}`) du module.
|
||||
pub fn actions() -> Vec<(&'static str, Handler)> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Données de pages (`GET /api/pages/{nom}`) du module.
|
||||
pub fn pages() -> Vec<(&'static str, Handler)> {
|
||||
vec![]
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Appels de l'interface vers le serveur hors routes REST classiques.
|
||||
//!
|
||||
//! - **Actions** — `POST /api/actions/{nom}`, corps = tableau JSON des arguments.
|
||||
//! Remplacent les « server actions » Next.js : l'interface garde les mêmes
|
||||
//! fonctions (`getUsers()`, `saveSnapshot(…)`…), devenues de simples appels
|
||||
//! HTTP (cf. `src/lib/rpc.ts`). Une erreur renvoie `{ error }` avec un statut
|
||||
//! 4xx/5xx, que le client relève comme une exception.
|
||||
//! - **Pages** — `GET /api/pages/{nom}?…` : les données qu'une page serveur
|
||||
//! Next.js calculait avant rendu (tableau de bord, hit-parade…), paramètres
|
||||
//! d'URL compris.
|
||||
//!
|
||||
//! Chaque module déclare les siennes via `actions()` / `pages()`.
|
||||
|
||||
use crate::auth::{session, Claims, UtilisateurCourant};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use axum::extract::{Path, Query};
|
||||
use axum::http::{header, HeaderMap};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use futures::future::BoxFuture;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// Contexte d'un appel : jeton de session, paramètres d'URL, arguments.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Ctx {
|
||||
pub token: Option<Claims>,
|
||||
pub query: HashMap<String, String>,
|
||||
pub args: Vec<Value>,
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
/// Utilisateur connecté (401 sinon).
|
||||
pub async fn session(&self) -> AppResult<UtilisateurCourant> {
|
||||
crate::auth::require_session(self.token.as_ref()).await
|
||||
}
|
||||
/// Administrateur connecté (401/403 sinon).
|
||||
pub async fn admin(&self) -> AppResult<UtilisateurCourant> {
|
||||
crate::auth::require_admin(self.token.as_ref()).await
|
||||
}
|
||||
/// Argument n° `i` désérialisé (`null` s'il est absent).
|
||||
pub fn arg<T: DeserializeOwned>(&self, i: usize) -> AppResult<T> {
|
||||
let v = self.args.get(i).cloned().unwrap_or(Value::Null);
|
||||
serde_json::from_value(v).map_err(|e| AppError::bad_request(format!("Argument {i} invalide : {e}")))
|
||||
}
|
||||
/// Argument brut n° `i`.
|
||||
pub fn raw(&self, i: usize) -> Value {
|
||||
self.args.get(i).cloned().unwrap_or(Value::Null)
|
||||
}
|
||||
/// Paramètre d'URL (page), vide → `None`.
|
||||
pub fn q(&self, name: &str) -> Option<&str> {
|
||||
self.query.get(name).map(|s| s.as_str()).filter(|s| !s.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
pub type Handler = fn(Ctx) -> BoxFuture<'static, AppResult<Value>>;
|
||||
|
||||
/// Sérialise un résultat en JSON.
|
||||
pub fn json<T: Serialize>(v: T) -> AppResult<Value> {
|
||||
serde_json::to_value(v).map_err(|e| AppError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Transforme une fonction `async fn(Ctx) -> AppResult<Value>` en [`Handler`].
|
||||
#[macro_export]
|
||||
macro_rules! handler {
|
||||
($f:path) => {
|
||||
(|c: $crate::rpc::Ctx| -> ::futures::future::BoxFuture<'static, $crate::error::AppResult<::serde_json::Value>> {
|
||||
Box::pin($f(c))
|
||||
}) as $crate::rpc::Handler
|
||||
};
|
||||
}
|
||||
|
||||
static ACTIONS: LazyLock<HashMap<&'static str, Handler>> = LazyLock::new(|| {
|
||||
let mut m = HashMap::new();
|
||||
for (name, h) in crate::modules_actions() {
|
||||
if m.insert(name, h).is_some() {
|
||||
panic!("action en double : {name}");
|
||||
}
|
||||
}
|
||||
m
|
||||
});
|
||||
|
||||
static PAGES: LazyLock<HashMap<&'static str, Handler>> = LazyLock::new(|| {
|
||||
let mut m = HashMap::new();
|
||||
for (name, h) in crate::modules_pages() {
|
||||
if m.insert(name, h).is_some() {
|
||||
panic!("page en double : {name}");
|
||||
}
|
||||
}
|
||||
m
|
||||
});
|
||||
|
||||
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> {
|
||||
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) {
|
||||
Some(Value::Array(a)) => a,
|
||||
Some(Value::Null) | None => vec![],
|
||||
Some(other) => vec![other],
|
||||
};
|
||||
let ctx = Ctx { token: session::from_headers(&headers), query: HashMap::new(), args };
|
||||
Ok(no_store(h(ctx).await?))
|
||||
}
|
||||
|
||||
async fn call_page(Path(name): Path<String>, headers: HeaderMap, Query(query): Query<HashMap<String, String>>) -> AppResult<Response> {
|
||||
let h = PAGES.get(name.as_str()).ok_or_else(|| AppError::not_found(format!("Page inconnue : {name}")))?;
|
||||
let ctx = Ctx { token: session::from_headers(&headers), query, args: vec![] };
|
||||
Ok(no_store(h(ctx).await?))
|
||||
}
|
||||
|
||||
pub fn router() -> Router {
|
||||
// Force la construction des registres au démarrage (doublons détectés tôt).
|
||||
LazyLock::force(&ACTIONS);
|
||||
LazyLock::force(&PAGES);
|
||||
Router::new()
|
||||
.route("/api/actions/{name}", post(call_action))
|
||||
.route("/api/pages/{name}", get(call_page))
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Service de l'interface (SPA React compilée par Vite) et garde des pages.
|
||||
//!
|
||||
//! Les fichiers existants du répertoire statique sont servis tels quels. Toute
|
||||
//! autre URL hors `/api` renvoie `index.html` (routage côté navigateur), après
|
||||
//! les mêmes contrôles que l'ancien middleware Next.js :
|
||||
//! - non connecté → `/login` ;
|
||||
//! - connecté sur `/login` ou `/` → `/dashboard` ;
|
||||
//! - `/settings` et `/admin/*` réservés aux administrateurs.
|
||||
|
||||
use crate::auth::session;
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, HeaderValue, Request, StatusCode, Uri};
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use axum::Router;
|
||||
use std::path::PathBuf;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::set_header::SetResponseHeaderLayer;
|
||||
|
||||
pub fn static_dir() -> PathBuf {
|
||||
std::env::var("STATIC_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("web"))
|
||||
}
|
||||
|
||||
async fn page(req: Request<Body>) -> Response {
|
||||
let uri: Uri = req.uri().clone();
|
||||
let path = uri.path();
|
||||
if path.starts_with("/api/") || path == "/api" {
|
||||
return (StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "error": "Route inconnue" }))).into_response();
|
||||
}
|
||||
|
||||
let claims = session::from_headers(req.headers());
|
||||
let is_login = path == "/login";
|
||||
let is_public = is_login || path.starts_with("/public/");
|
||||
|
||||
let user = match claims.as_ref() {
|
||||
Some(_) => crate::auth::require_session(claims.as_ref()).await.ok(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
match &user {
|
||||
None if !is_public => return Redirect::to("/login").into_response(),
|
||||
Some(_) if is_public || path == "/" => return Redirect::to("/dashboard").into_response(),
|
||||
Some(u) if (path.starts_with("/settings") || path.starts_with("/admin")) && !u.is_admin() => {
|
||||
return Redirect::to("/dashboard").into_response()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match tokio::fs::read(static_dir().join("index.html")).await {
|
||||
Ok(html) => (
|
||||
[
|
||||
(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")),
|
||||
(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")),
|
||||
],
|
||||
html,
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => (StatusCode::SERVICE_UNAVAILABLE, "Interface non compilée (index.html introuvable).").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router() -> Router {
|
||||
let dir = static_dir();
|
||||
let assets = Router::new()
|
||||
.nest_service("/assets", ServeDir::new(dir.join("assets")))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||
));
|
||||
let files = ServeDir::new(&dir)
|
||||
.append_index_html_on_directories(false)
|
||||
.fallback(tower::service_fn(|req: Request<Body>| async move { Ok::<_, std::convert::Infallible>(page(req).await) }));
|
||||
assets.fallback_service(files)
|
||||
}
|
||||
Reference in new issue
Block a user