//! SCOPENET admin panel. // SQLx maps several multi-column queries directly to tuples; aliases would // obscure the selected column order without simplifying the query boundary. #![allow(clippy::type_complexity)] pub mod auth; pub mod config; pub mod db; pub mod error; pub mod livemap; pub mod net; pub mod packs; pub mod progression; pub mod routes; pub mod seed; pub mod state; pub mod store; pub mod textures; pub mod yggdrasil; 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 path = cfg.signing_key_path(); let ygg = tokio::task::spawn_blocking(move || yggdrasil::keys::Keys::load_or_create(&path)).await??; build_state_with_keys(cfg, db, Arc::new(ygg)).await } /// Like [`build_state`] with a given auth-server key (tests reuse one key). pub async fn build_state_with_keys(cfg: config::Config, db: sqlx::SqlitePool, ygg: Arc) -> anyhow::Result { let secret = jwt_secret(&cfg)?; progression::set_curve(&progression::load_pool(&db).await.map_err(|e| anyhow::anyhow!(e.message))?); Ok(AppState { db, ygg, keys: Arc::new(auth::Keys::new(&secret)), http: scopenet_core::http::client(), login_guard: Arc::new(auth::LoginGuard::default()), livemap: Arc::new(livemap::LiveMap::new(&cfg.data_dir, cfg.vantage_bin.clone(), cfg.vantage_assets.clone(), cfg.vantage_args.clone())), 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) } }; auth::create_user(state, &state.cfg.admin_username, &password, None, "admin", "active") .await .map_err(|e| anyhow::anyhow!(e.message))?; 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)) .route("/download/launcher/{platform}", axum::routing::get(routes::landing::download_launcher)) .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()))) .nest_service("/downloads", ServeDir::new(state.cfg.downloads_dir())) .fallback_service(spa) // Lets authlib-injector users enter just the panel URL (API Location Indication). .layer(SetResponseHeaderLayer::if_not_present( header::HeaderName::from_static("x-authlib-injector-api-location"), HeaderValue::from_static("/api/yggdrasil/"), )) .layer(CompressionLayer::new()) .layer(TraceLayer::new_for_http()) .with_state(state) }