Files
imagod-web/src/server/mod.rs
T
bruno 62e63e441a fix: CORS layer — corrige 'serveur injoignable' dans le navigateur
- Ajout CorsLayer::permissive() au router axum
- Répond aux requêtes OPTIONS (preflight CORS)
- Le header X-API-Key rendait les requêtes non-simples → preflight obligatoire
- Sans réponse OPTIONS, le navigateur bloquait → 'Failed to fetch'
- Curl fonctionnait car pas de preflight
2026-06-25 15:33:02 -04:00

150 lines
5.2 KiB
Rust

//! Serveur web — Dashboard embarqué (axum)
//!
//! Phase 9: API REST + WebSocket + frontend embarqué via rust-embed
pub mod routes;
pub mod ws;
use axum::Router;
use rust_embed::RustEmbed;
use std::net::SocketAddr;
use std::sync::Arc;
use std::collections::HashSet;
use tokio::sync::Mutex;
use tower_http::cors::CorsLayer;
use crate::imago_client::ImagoClient;
use crate::config::Config;
/// Frontend embarqué dans le binaire
#[derive(RustEmbed)]
#[folder = "src/frontend/"]
struct FrontendAssets;
/// Démarre le serveur web avec le dashboard complet
pub async fn serve_with_config(host: &str, port: u16, config: &Config) -> anyhow::Result<()> {
// Initialiser le client Imago (ne bloque pas si indisponible)
let imago = Arc::new(
ImagoClient::new(config.imago.url.clone(), config.imago.api_key.clone())
.unwrap_or_else(|e| {
tracing::warn!("Client Imago non initialisé : {} — dashboard en mode dégradé", e);
// Client fallback avec URL vide
ImagoClient::new(String::new(), String::new()).unwrap()
})
);
// Hub WebSocket
let ws_hub = ws::WsHub::new();
// Base de données de dédoublonnage (partagée avec le watcher)
let data_dir = if cfg!(target_os = "windows") {
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
std::path::PathBuf::from(appdata)
} else {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
std::path::PathBuf::from(home).join(".config")
};
let dedup_db_path = data_dir.join("imagod-web").join("imagod.db");
// État partagé
let state = routes::AppState {
imago,
base_url: config.imago.url.clone(),
api_key: config.imago.api_key.clone(),
ws_hub: ws_hub.clone(),
dedup_db_path,
in_flight: Mutex::new(HashSet::new()),
};
// Router principal — avec CORS permissif pour les requêtes cross-origin et preflight
let app = Router::new()
.merge(routes::api_routes(state))
.route("/ws", axum::routing::get(ws::ws_handler).with_state(ws_hub))
.fallback(serve_static)
.layer(CorsLayer::permissive());
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
tracing::info!("Dashboard web démarré sur http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
/// Sert le frontend statique embarqué via rust-embed
async fn serve_static(
req: axum::http::Request<axum::body::Body>,
) -> axum::response::Response {
let path = req.uri().path();
let path = path.trim_start_matches('/');
// Par défaut, servir index.html
let asset_path = if path.is_empty() { "index.html".to_string() } else { path.to_string() };
// Essayer avec le chemin exact
if let Some(content) = FrontendAssets::get(&asset_path) {
let mime = mime_guess(&asset_path);
return axum::http::Response::builder()
.header("Content-Type", mime)
.header("Cache-Control", "public, max-age=3600")
.body(axum::body::Body::from(content.data.to_vec()))
.unwrap();
}
// Essayer avec .html si pas d'extension
if !asset_path.contains('.') {
let html_path = format!("{}.html", asset_path);
if let Some(content) = FrontendAssets::get(&html_path) {
return axum::http::Response::builder()
.header("Content-Type", "text/html; charset=utf-8")
.body(axum::body::Body::from(content.data.to_vec()))
.unwrap();
}
}
// Fallback SPA: toutes les routes inconnues renvoient index.html
if let Some(index) = FrontendAssets::get("index.html") {
axum::http::Response::builder()
.header("Content-Type", "text/html; charset=utf-8")
.body(axum::body::Body::from(index.data.to_vec()))
.unwrap()
} else {
axum::http::Response::builder()
.status(axum::http::StatusCode::NOT_FOUND)
.header("Content-Type", "text/plain")
.body(axum::body::Body::from("404 — Page non trouvée"))
.unwrap()
}
}
/// Devine le type MIME à partir de l'extension
fn mime_guess(path: &str) -> &'static str {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
match ext {
"html" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" => "application/javascript; charset=utf-8",
"json" => "application/json",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"wasm" => "application/wasm",
"woff" => "font/woff",
"woff2" => "font/woff2",
_ => "application/octet-stream",
}
}
// ─── Legacy: compatibilité avec l'ancienne signature (Phase 7) ─────────────
/// Version simplifiée sans config (utilise les défauts)
pub async fn serve(host: &str, port: u16) -> anyhow::Result<()> {
let config = Config::default();
serve_with_config(host, port, &config).await
}