Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
120 lines
4.7 KiB
Rust
120 lines
4.7 KiB
Rust
//! 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<Vec<u8>> {
|
|
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<AppState> {
|
|
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<yggdrasil::keys::Keys>) -> anyhow::Result<AppState> {
|
|
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)
|
|
}
|