//! ScopeNet admin panel. pub mod auth; pub mod config; pub mod db; pub mod error; pub mod packs; pub mod routes; pub mod state; pub mod store; use axum::http::{header, HeaderValue}; use axum::Router; use rand::RngCore; use state::AppState; use std::sync::Arc; use tower_http::compression::CompressionLayer; use tower_http::services::{ServeDir, ServeFile}; use tower_http::set_header::SetResponseHeaderLayer; use tower_http::trace::TraceLayer; /// Load (or create) the JWT signing secret. pub fn jwt_secret(cfg: &config::Config) -> anyhow::Result> { if let Some(s) = &cfg.jwt_secret { return Ok(s.as_bytes().to_vec()); } let path = cfg.data_dir.join("jwt.secret"); if let Ok(bytes) = std::fs::read(&path) { if bytes.len() >= 32 { return Ok(bytes); } } let mut bytes = vec![0u8; 64]; rand::thread_rng().fill_bytes(&mut bytes); std::fs::create_dir_all(&cfg.data_dir)?; std::fs::write(&path, &bytes)?; Ok(bytes) } pub async fn build_state(cfg: config::Config, db: sqlx::SqlitePool) -> anyhow::Result { let secret = jwt_secret(&cfg)?; Ok(AppState { db, keys: Arc::new(auth::Keys::new(&secret)), http: scopenet_core::http::client(), login_guard: Arc::new(auth::LoginGuard::default()), cfg: Arc::new(cfg), }) } /// Create the first admin account if none exists. pub async fn bootstrap_admin(state: &AppState) -> anyhow::Result<()> { let admins: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE role = 'admin'").fetch_one(&state.db).await?; if admins > 0 { return Ok(()); } let (password, generated) = match &state.cfg.admin_password { Some(p) => (p.clone(), false), None => { let mut bytes = [0u8; 12]; rand::thread_rng().fill_bytes(&mut bytes); (hex::encode(bytes), true) } }; let hash = auth::hash_password(&password).map_err(|e| anyhow::anyhow!(e.message))?; sqlx::query("INSERT INTO users (username, password_hash, role, status, created_at) VALUES (?, ?, 'admin', 'active', ?)") .bind(&state.cfg.admin_username) .bind(hash) .bind(db::now()) .execute(&state.db) .await?; if generated { tracing::warn!("============================================================"); tracing::warn!(" Created admin account '{}' with password: {password}", state.cfg.admin_username); tracing::warn!(" Set ADMIN_PASSWORD to choose your own. Change it after login!"); tracing::warn!("============================================================"); } else { tracing::info!("created admin account '{}'", state.cfg.admin_username); } Ok(()) } pub fn app(state: AppState) -> Router { let web = &state.cfg.web_dir; let spa = ServeDir::new(web).fallback(ServeFile::new(web.join("index.html"))); let long_cache = SetResponseHeaderLayer::overriding(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=86400")); Router::new() .route("/healthz", axum::routing::get(routes::public::health)) .merge(routes::api(&state)) .nest_service("/files", ServeDir::new(state.cfg.files_dir())) .nest_service("/uploads", tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir()))) .fallback_service(spa) .layer(CompressionLayer::new()) .layer(TraceLayer::new_for_http()) .with_state(state) }