- 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
150 lines
5.2 KiB
Rust
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
|
|
}
|