Files
SCOPENET-MC/panel/server/src/lib.rs
T
Claude 5dc8a4f2f1 Add Docker image, Compose, CI/CD workflows and docs
- Multi-arch (amd64/arm64) panel image: static musl binary on scratch,
  cross-compiled with cargo-zigbuild (no QEMU), non-root, healthcheck
- docker-compose.yml + .env.example for one-command deployment
- CI: fmt, clippy, tests, web type-checks, Windows launcher build,
  real-network installs of vanilla/Fabric/Quilt/Forge/NeoForge,
  Docker build
- Release: Windows NSIS installer with baked-in panel URL, GHCR image
  push and GitHub release (tag push or manual dispatch)
- README, admin guide, Microsoft auth setup, architecture, development
- rustfmt config and formatting pass

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
2026-09-28 05:19:36 +00:00

98 lines
3.4 KiB
Rust

//! 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<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 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)
}