//! Game server integration: the SCOPENET Paper plugin and Fabric/Forge mods //! report to these endpoints with a per-server token, and admins manage the //! servers (and read the stats they collect) from the panel. use crate::auth::{self, AdminUser, AuthUser, UserRow}; use crate::error::{AppError, AppResult}; use crate::net; use crate::state::AppState; use crate::store; use crate::yggdrasil::{dashed, user_by_uuid}; use axum::extract::{FromRequestParts, Path, Query, State}; use axum::http::request::Parts; use axum::http::HeaderMap; use axum::Json; use rand::Rng; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; /// How often integrations should call `/sync`. pub const SYNC_INTERVAL_SECS: i64 = 30; /// A server that hasn't synced for this long is shown as offline. const STALE_SECS: i64 = 90; /// A launcher launch from the same IP within this window counts as "joined /// through the launcher". const LAUNCHER_WINDOW_HOURS: i64 = 12; const EVENT_RETENTION_DAYS: i64 = 60; const MAX_EVENTS_PER_SYNC: usize = 500; const MAX_ONLINE: usize = 5000; use crate::db::now; /// Same format as `db::now()`, so timestamps compare as strings. fn ts(t: chrono::DateTime) -> String { t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } fn ago(d: chrono::Duration) -> String { ts(chrono::Utc::now() - d) } fn stale_cutoff() -> String { ago(chrono::Duration::seconds(STALE_SECS)) } fn hash_token(token: &str) -> String { hex::encode(Sha256::digest(token.as_bytes())) } fn new_token() -> String { const CHARS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; let mut rng = rand::thread_rng(); let body: String = (0..40).map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char).collect(); format!("sn_{body}") } fn clip(s: &str, max: usize) -> String { s.chars().filter(|c| !c.is_control()).take(max).collect() } #[derive(Debug, Clone, sqlx::FromRow, Serialize)] pub struct ServerRow { pub id: i64, pub name: String, #[serde(skip)] pub token_hash: String, pub token_hint: String, /// `all` (anyone who can connect), `members` (panel accounts) or `groups`. pub access: String, #[serde(skip)] pub allowed_groups: String, pub require_launcher: bool, pub software: Option, pub mc_version: Option, pub plugin_version: Option, pub online_mode: Option, pub max_players: i64, pub online_count: i64, pub tps: Option, pub last_seen: Option, pub created_at: String, } impl ServerRow { fn groups(&self) -> Vec { serde_json::from_str(&self.allowed_groups).unwrap_or_default() } fn is_online(&self) -> bool { self.last_seen.as_deref().is_some_and(|t| t >= stale_cutoff().as_str()) } } #[derive(Serialize)] pub struct ServerView { #[serde(flatten)] server: ServerRow, allowed_groups: Vec, online: bool, players: i64, } fn view(server: ServerRow) -> ServerView { let online = server.is_online(); ServerView { allowed_groups: server.groups(), players: if online { server.online_count } else { 0 }, online, server } } // --------------------------------------------------------------------------- // Token-authenticated game server API (/api/server/v1) // --------------------------------------------------------------------------- /// The game server behind a valid `Authorization: Bearer sn_…` token. pub struct GameServer(pub ServerRow); impl FromRequestParts for GameServer { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { let token = auth::bearer(parts).ok_or_else(|| AppError::unauthorized("missing server token"))?; let row: Option = sqlx::query_as("SELECT * FROM game_servers WHERE token_hash = ?") .bind(hash_token(token.trim())) .fetch_optional(&state.db) .await?; row.map(GameServer).ok_or_else(|| AppError::unauthorized("unknown server token — create one under Servers in the panel")) } } #[derive(Deserialize, Default)] #[serde(default)] pub struct Hello { software: Option, mc_version: Option, plugin_version: Option, online_mode: Option, max_players: i64, } /// Sent once when the plugin/mod starts: records what the server runs and /// returns the settings the integration needs. pub async fn hello( GameServer(server): GameServer, State(state): State, headers: HeaderMap, Json(h): Json, ) -> AppResult> { sqlx::query( "UPDATE game_servers SET software = ?, mc_version = ?, plugin_version = ?, online_mode = ?, max_players = ?, online_count = 0, last_seen = ? WHERE id = ?", ) .bind(h.software.as_deref().map(|s| clip(s, 64))) .bind(h.mc_version.as_deref().map(|s| clip(s, 32))) .bind(h.plugin_version.as_deref().map(|s| clip(s, 32))) .bind(h.online_mode) .bind(h.max_players.clamp(0, 100_000)) .bind(now()) .bind(server.id) .execute(&state.db) .await?; // Fresh start: nobody is online yet. sqlx::query("DELETE FROM server_online WHERE server_id = ?").bind(server.id).execute(&state.db).await?; let base = net::public_base(&state, &headers).await; let branding = store::branding(&state).await?; Ok(Json(json!({ "server_id": server.id, "name": server.name, "brand": branding.name, "yggdrasil_url": format!("{base}/api/yggdrasil"), "sync_interval_secs": SYNC_INTERVAL_SECS, "access": server.access, "require_launcher": server.require_launcher, }))) } #[derive(Deserialize)] pub struct LoginCheck { uuid: String, name: String, #[serde(default)] ip: Option, } #[derive(Serialize, Debug)] pub struct LoginVerdict { allowed: bool, /// Shown to the player on the disconnect screen when refused. message: Option, /// The panel account behind the player, if any. account: Option, } impl LoginVerdict { fn deny(message: impl Into) -> Self { Self { allowed: false, message: Some(message.into()), account: None } } } /// Decides whether a player may join. Called by the integration before the /// player is let in. pub async fn login( GameServer(server): GameServer, State(state): State, Json(req): Json, ) -> AppResult> { let brand = store::branding(&state).await?.name; // Names are not proof of identity. Only the UUID authenticated by the // server's online-mode session check may select a panel account. let user = user_by_uuid(&state, &req.uuid).await?; if user.is_none() { let reserved: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM reserved_usernames WHERE name=?)").bind(&req.name).fetch_one(&state.db).await?; if reserved { return Ok(Json(LoginVerdict::deny("That player name belongs to another account."))); } } if user.as_ref().is_some_and(|u| !u.username.eq_ignore_ascii_case(&req.name)) { return Ok(Json(LoginVerdict::deny("Your player name does not match your account."))); } if server.require_launcher && req.ip.as_deref().is_none_or(|ip| ip.trim().is_empty()) { return Ok(Json(LoginVerdict::deny("Your connection address could not be verified. Please reconnect through the launcher."))); } Ok(Json(check_login(&state, &server, user.as_ref(), req.ip.as_deref(), &brand).await?)) } fn canonical_ip(ip: std::net::IpAddr) -> std::net::IpAddr { match ip { std::net::IpAddr::V4(v4) => std::net::IpAddr::V4(v4), std::net::IpAddr::V6(v6) => { if let Some(v4) = v6.to_ipv4_mapped() { std::net::IpAddr::V4(v4) } else { std::net::IpAddr::V6(v6) } } } } fn is_private_or_local(ip: &std::net::IpAddr) -> bool { match canonical_ip(*ip) { std::net::IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(), std::net::IpAddr::V6(v6) => v6.is_loopback(), } } pub fn ips_match(sess_str: &str, req_str: &str) -> bool { let s = sess_str.trim(); let r = req_str.trim(); if s.eq_ignore_ascii_case(r) { return true; } let (Ok(ip_a), Ok(ip_b)) = (s.parse::(), r.parse::()) else { return false; }; let a = canonical_ip(ip_a); let b = canonical_ip(ip_b); if a == b { return true; } if a.is_loopback() && b.is_loopback() { return true; } if is_private_or_local(&a) && is_private_or_local(&b) { return true; } if is_private_or_local(&a) { return true; } match (a, b) { (std::net::IpAddr::V4(v4_a), std::net::IpAddr::V4(v4_b)) => { v4_a.octets()[0..3] == v4_b.octets()[0..3] } (std::net::IpAddr::V6(v6_a), std::net::IpAddr::V6(v6_b)) => { v6_a.segments()[0..4] == v6_b.segments()[0..4] } _ => false, } } async fn verify_launcher_ip( state: &AppState, user_id: Option, req_ip: Option<&str>, since: &str, ) -> AppResult { let req_ip = match req_ip { Some(ip) if !ip.trim().is_empty() => ip.trim(), _ => return Ok(false), }; let sessions: Vec = match user_id { Some(uid) => { sqlx::query_scalar("SELECT ip FROM launcher_sessions WHERE user_id = ? AND created_at >= ? ORDER BY id DESC LIMIT 50") .bind(uid) .bind(since) .fetch_all(&state.db) .await? } None => { sqlx::query_scalar("SELECT ip FROM launcher_sessions WHERE user_id IS NULL AND created_at >= ? ORDER BY id DESC LIMIT 50") .bind(since) .fetch_all(&state.db) .await? } }; for sess_ip in sessions { if ips_match(&sess_ip, req_ip) { return Ok(true); } } Ok(false) } async fn check_login( state: &AppState, server: &ServerRow, user: Option<&UserRow>, ip: Option<&str>, brand: &str, ) -> AppResult { let Some(user) = user else { if server.access == "all" { if server.require_launcher { let since = ago(chrono::Duration::hours(LAUNCHER_WINDOW_HOURS)); let launched = verify_launcher_ip(state, None, ip, &since).await?; if !launched { return Ok(LoginVerdict::deny(format!("Please join through the {brand} launcher."))); } } return Ok(LoginVerdict { allowed: true, message: None, account: None }); } else { return Ok(LoginVerdict::deny(format!("You need a {brand} account to join this server.\nCreate one in the {brand} launcher."))); } }; match user.status.as_str() { "active" => {} "pending" => return Ok(LoginVerdict::deny(format!("Your {brand} account is still waiting for approval."))), _ => { let reason = user.status_reason.as_deref().filter(|r| !r.trim().is_empty()); return Ok(LoginVerdict::deny(match reason { Some(r) => format!("Your {brand} account is disabled.\n\n{r}"), None => format!("Your {brand} account is disabled."), })); } } let groups = auth::user_groups(state, user.id).await?; if server.access == "groups" && !user.is_admin() { let allowed = server.groups(); if !groups.iter().any(|g| allowed.iter().any(|a| a.eq_ignore_ascii_case(g))) { return Ok(LoginVerdict::deny("You don't have access to this server.")); } } if server.require_launcher { let since = ago(chrono::Duration::hours(LAUNCHER_WINDOW_HOURS)); let launched = verify_launcher_ip(state, Some(user.id), ip, &since).await?; if !launched { return Ok(LoginVerdict::deny(format!("Please join through the {brand} launcher."))); } } Ok(LoginVerdict { allowed: true, message: None, account: Some(json!({ "username": user.username, "uuid": user.uuid, "role": user.role, "groups": groups })), }) } #[derive(Deserialize)] pub struct OnlinePlayer { uuid: String, name: String, } /// Counters accumulated since the previous sync (deltas, not totals). #[derive(Deserialize, Default)] #[serde(default)] pub struct StatDelta { uuid: String, name: String, playtime_secs: i64, joins: i64, deaths: i64, player_kills: i64, mob_kills: i64, blocks_broken: i64, blocks_placed: i64, messages: i64, } #[derive(Deserialize)] pub struct GameEvent { #[serde(default)] uuid: Option, #[serde(default)] name: Option, kind: String, #[serde(default)] detail: Option, /// Epoch millis when it happened (defaults to now). #[serde(default)] at: Option, } #[derive(Deserialize, Default)] #[serde(default)] pub struct Sync { /// Stable across retries. Older integrations may omit it. batch_id: Option, tps: Option, online: Vec, stats: Vec, events: Vec, } /// Periodic heartbeat: who's online, TPS, stat deltas and notable events. /// Answers with players that must be kicked (accounts disabled meanwhile). pub async fn sync(GameServer(server): GameServer, State(state): State, Json(s): Json) -> AppResult> { let at_now = now(); let mut tx = state.db.begin().await?; // Record the receipt in the same transaction as the deltas. A response // lost after commit can then be retried without counting activity twice. let fresh = if let Some(batch) = &s.batch_id { if uuid::Uuid::parse_str(batch).is_err() { return Err(AppError::bad_request("batch_id must be a UUID")); } sqlx::query("INSERT OR IGNORE INTO server_sync_receipts (server_id, batch_id, created_at) VALUES (?, ?, ?)") .bind(server.id) .bind(batch) .bind(&at_now) .execute(&mut *tx) .await? .rows_affected() > 0 } else { true }; let online: Vec<(String, String)> = s.online.iter().take(MAX_ONLINE).filter_map(|p| Some((dashed(&p.uuid)?, clip(&p.name, 16)))).collect(); sqlx::query("UPDATE game_servers SET last_seen = ?, online_count = ?, tps = ? WHERE id = ?") .bind(&at_now) .bind(online.len() as i64) .bind(s.tps.map(|t| t.clamp(0.0, 1000.0))) .bind(server.id) .execute(&mut *tx) .await?; // Keep join times for players that stay online. let keep = serde_json::to_string(&online.iter().map(|(u, _)| u).collect::>()).unwrap_or_default(); sqlx::query("DELETE FROM server_online WHERE server_id = ? AND uuid NOT IN (SELECT value FROM json_each(?))") .bind(server.id) .bind(&keep) .execute(&mut *tx) .await?; for (uuid, name) in &online { sqlx::query("INSERT INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET name = excluded.name") .bind(server.id) .bind(uuid) .bind(name) .bind(&at_now) .execute(&mut *tx) .await?; } for d in s.stats.iter().filter(|_| fresh) { let Some(uuid) = dashed(&d.uuid) else { continue }; let n = |v: i64| v.clamp(0, 1_000_000); sqlx::query( "INSERT INTO player_stats (server_id, uuid, name, playtime_secs, joins, deaths, player_kills, mob_kills, blocks_broken, blocks_placed, messages, first_seen, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (server_id, uuid) DO UPDATE SET name = excluded.name, playtime_secs = playtime_secs + excluded.playtime_secs, joins = joins + excluded.joins, deaths = deaths + excluded.deaths, player_kills = player_kills + excluded.player_kills, mob_kills = mob_kills + excluded.mob_kills, blocks_broken = blocks_broken + excluded.blocks_broken, blocks_placed = blocks_placed + excluded.blocks_placed, messages = messages + excluded.messages, last_seen = excluded.last_seen", ) .bind(server.id) .bind(&uuid) .bind(clip(&d.name, 16)) .bind(n(d.playtime_secs)) .bind(n(d.joins)) .bind(n(d.deaths)) .bind(n(d.player_kills)) .bind(n(d.mob_kills)) .bind(n(d.blocks_broken)) .bind(n(d.blocks_placed)) .bind(n(d.messages)) .bind(&at_now) .bind(&at_now) .execute(&mut *tx) .await?; } for e in s.events.iter().filter(|_| fresh).take(MAX_EVENTS_PER_SYNC) { let kind = clip(&e.kind, 24).to_ascii_lowercase(); if kind.is_empty() { continue; } let at = e.at.and_then(chrono::DateTime::from_timestamp_millis) .filter(|t| (chrono::Utc::now() - *t).num_days().abs() < 2) .map(ts) .unwrap_or_else(|| at_now.clone()); sqlx::query("INSERT INTO server_events (server_id, uuid, name, kind, detail, created_at) VALUES (?, ?, ?, ?, ?, ?)") .bind(server.id) .bind(e.uuid.as_deref().and_then(dashed)) .bind(e.name.as_deref().map(|n| clip(n, 16))) .bind(kind) .bind(e.detail.as_deref().map(|d| clip(d, 256))) .bind(at) .execute(&mut *tx) .await?; } tx.commit().await?; // Occasional housekeeping. if rand::thread_rng().gen_ratio(1, 50) { let cutoff = ago(chrono::Duration::days(EVENT_RETENTION_DAYS)); sqlx::query("DELETE FROM server_events WHERE created_at < ?").bind(cutoff).execute(&state.db).await?; } // Players whose access was revoked while they were online. let brand = store::branding(&state).await?.name; let mut kick = Vec::new(); for (uuid, _) in &online { let Some(user) = user_by_uuid(&state, uuid).await? else { continue }; let verdict = check_login(&state, &server, Some(&user), None, &brand).await?; if !verdict.allowed { kick.push(json!({ "uuid": uuid, "message": verdict.message })); } } Ok(Json(json!({ "ok": true, "kick": kick, "sync_interval_secs": SYNC_INTERVAL_SECS }))) } // --------------------------------------------------------------------------- // Admin API // --------------------------------------------------------------------------- pub async fn list(_: AdminUser, State(state): State) -> AppResult>> { let rows: Vec = sqlx::query_as("SELECT * FROM game_servers ORDER BY name COLLATE NOCASE").fetch_all(&state.db).await?; Ok(Json(rows.into_iter().map(view).collect())) } #[derive(Deserialize)] pub struct ServerInput { name: String, #[serde(default = "default_access")] access: String, #[serde(default)] allowed_groups: Vec, #[serde(default)] require_launcher: bool, } fn default_access() -> String { "all".into() } impl ServerInput { fn validate(&self) -> AppResult<()> { if self.name.trim().is_empty() || self.name.chars().count() > 48 { return Err(AppError::bad_request("give the server a name (up to 48 characters)")); } if !matches!(self.access.as_str(), "all" | "members" | "groups") { return Err(AppError::bad_request("access must be all, members or groups")); } if self.access == "groups" && self.allowed_groups.is_empty() { return Err(AppError::bad_request("pick at least one group")); } Ok(()) } } async fn get_server(state: &AppState, id: i64) -> AppResult { sqlx::query_as("SELECT * FROM game_servers WHERE id = ?") .bind(id) .fetch_optional(&state.db) .await? .ok_or_else(|| AppError::not_found("server not found")) } pub async fn create(_: AdminUser, State(state): State, Json(input): Json) -> AppResult> { input.validate()?; let token = new_token(); let id: i64 = sqlx::query_scalar( "INSERT INTO game_servers (name, token_hash, token_hint, access, allowed_groups, require_launcher, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id", ) .bind(input.name.trim()) .bind(hash_token(&token)) .bind(&token[token.len() - 4..]) .bind(&input.access) .bind(serde_json::to_string(&input.allowed_groups).unwrap_or_else(|_| "[]".into())) .bind(input.require_launcher) .bind(now()) .fetch_one(&state.db) .await?; let server = get_server(&state, id).await?; Ok(Json(json!({ "server": view(server), "token": token }))) } pub async fn update( _: AdminUser, State(state): State, Path(id): Path, Json(input): Json, ) -> AppResult> { input.validate()?; get_server(&state, id).await?; sqlx::query("UPDATE game_servers SET name = ?, access = ?, allowed_groups = ?, require_launcher = ? WHERE id = ?") .bind(input.name.trim()) .bind(&input.access) .bind(serde_json::to_string(&input.allowed_groups).unwrap_or_else(|_| "[]".into())) .bind(input.require_launcher) .bind(id) .execute(&state.db) .await?; Ok(Json(view(get_server(&state, id).await?))) } pub async fn regenerate_token(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { get_server(&state, id).await?; let token = new_token(); sqlx::query("UPDATE game_servers SET token_hash = ?, token_hint = ? WHERE id = ?") .bind(hash_token(&token)) .bind(&token[token.len() - 4..]) .bind(id) .execute(&state.db) .await?; Ok(Json(json!({ "token": token }))) } pub async fn remove(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { let done = sqlx::query("DELETE FROM game_servers WHERE id = ?").bind(id).execute(&state.db).await?; if done.rows_affected() == 0 { return Err(AppError::not_found("server not found")); } Ok(Json(json!({ "ok": true }))) } #[derive(Serialize, sqlx::FromRow)] pub struct StatRow { pub server_id: i64, pub uuid: String, pub name: String, pub playtime_secs: i64, pub joins: i64, pub deaths: i64, pub player_kills: i64, pub mob_kills: i64, pub blocks_broken: i64, pub blocks_placed: i64, pub messages: i64, pub first_seen: String, pub last_seen: String, } #[derive(Serialize, sqlx::FromRow)] pub struct EventRow { pub id: i64, pub server_id: i64, pub uuid: Option, pub name: Option, pub kind: String, pub detail: Option, pub created_at: String, } #[derive(Serialize, sqlx::FromRow)] pub struct OnlineRow { uuid: String, name: String, joined_at: String, } #[derive(Deserialize)] pub struct DetailQuery { #[serde(default = "default_sort")] sort: String, } fn default_sort() -> String { "playtime_secs".into() } pub async fn detail( _: AdminUser, State(state): State, Path(id): Path, Query(q): Query, ) -> AppResult> { let server = get_server(&state, id).await?; let online: Vec = if server.is_online() { sqlx::query_as("SELECT uuid, name, joined_at FROM server_online WHERE server_id = ? ORDER BY joined_at") .bind(id) .fetch_all(&state.db) .await? } else { Vec::new() }; // Whitelisted so the column name can be interpolated safely. let sort = match q.sort.as_str() { "joins" | "deaths" | "player_kills" | "mob_kills" | "blocks_broken" | "blocks_placed" | "messages" | "last_seen" => q.sort.as_str(), _ => "playtime_secs", }; let leaderboard: Vec = sqlx::query_as(&format!("SELECT * FROM player_stats WHERE server_id = ? ORDER BY {sort} DESC LIMIT 100")) .bind(id) .fetch_all(&state.db) .await?; let events: Vec = sqlx::query_as("SELECT * FROM server_events WHERE server_id = ? ORDER BY id DESC LIMIT 100").bind(id).fetch_all(&state.db).await?; let (players, playtime): (i64, Option) = sqlx::query_as("SELECT COUNT(*), SUM(playtime_secs) FROM player_stats WHERE server_id = ?").bind(id).fetch_one(&state.db).await?; Ok(Json(json!({ "server": view(server), "online_players": online, "leaderboard": leaderboard, "events": events, "totals": { "players": players, "playtime_secs": playtime.unwrap_or(0) }, }))) } /// Per-server stats and recent events for one account (Players page). pub async fn user_activity(_: AdminUser, State(state): State, Path(user_id): Path) -> AppResult> { let uuid: String = sqlx::query_scalar("SELECT uuid FROM users WHERE id = ?") .bind(user_id) .fetch_optional(&state.db) .await? .ok_or_else(|| AppError::not_found("user not found"))?; let rows: Vec = sqlx::query_as( "SELECT s.*, g.name AS server_name FROM player_stats s JOIN game_servers g ON g.id = s.server_id WHERE s.uuid = ? ORDER BY s.last_seen DESC", ) .bind(&uuid) .fetch_all(&state.db) .await?; let events: Vec = sqlx::query_as("SELECT * FROM server_events WHERE uuid = ? ORDER BY id DESC LIMIT 50").bind(&uuid).fetch_all(&state.db).await?; let online: Vec<(i64, String)> = sqlx::query_as( "SELECT g.id, g.name FROM server_online o JOIN game_servers g ON g.id = o.server_id WHERE o.uuid = ? AND g.last_seen >= ?", ) .bind(&uuid) .bind(stale_cutoff()) .fetch_all(&state.db) .await?; Ok(Json(json!({ "servers": rows, "events": events, "online_on": online.into_iter().map(|(id, name)| json!({"id": id, "name": name})).collect::>(), }))) } #[derive(Serialize, sqlx::FromRow)] pub struct StatRowNamed { #[sqlx(flatten)] #[serde(flatten)] pub stats: StatRow, pub server_name: String, } #[derive(Deserialize)] pub struct LeaderboardQuery { #[serde(default = "default_sort")] pub sort: String, #[serde(default = "default_limit")] pub limit: i64, } fn default_limit() -> i64 { 50 } #[derive(Serialize, sqlx::FromRow)] pub struct AggregatedStatRow { pub uuid: String, pub name: String, pub playtime_secs: i64, pub joins: i64, pub deaths: i64, pub player_kills: i64, pub mob_kills: i64, pub blocks_broken: i64, pub blocks_placed: i64, pub messages: i64, pub first_seen: String, pub last_seen: String, } /// Public list of servers with status and player counts. pub async fn public_servers(State(state): State) -> AppResult> { let servers: Vec = sqlx::query_as("SELECT * FROM game_servers ORDER BY name COLLATE NOCASE").fetch_all(&state.db).await?; let list: Vec = servers.into_iter().map(|s| { let is_online = s.is_online(); json!({ "id": s.id, "name": s.name, "online": is_online, "players_online": if is_online { s.online_count } else { 0 }, "players_max": s.max_players, "software": s.software, "mc_version": s.mc_version, "tps": s.tps, "last_seen": s.last_seen, }) }).collect(); Ok(Json(json!({ "servers": list }))) } /// Public server-specific leaderboard. pub async fn public_server_leaderboard( State(state): State, Path(id): Path, Query(q): Query, ) -> AppResult> { let server = get_server(&state, id).await?; let sort = match q.sort.as_str() { "joins" | "deaths" | "player_kills" | "mob_kills" | "blocks_broken" | "blocks_placed" | "messages" | "last_seen" => q.sort.as_str(), _ => "playtime_secs", }; let limit = q.limit.clamp(1, 100); let leaderboard: Vec = sqlx::query_as(&format!("SELECT * FROM player_stats WHERE server_id = ? ORDER BY {sort} DESC LIMIT {limit}")) .bind(id) .fetch_all(&state.db) .await?; let (players, playtime): (i64, Option) = sqlx::query_as("SELECT COUNT(*), SUM(playtime_secs) FROM player_stats WHERE server_id = ?").bind(id).fetch_one(&state.db).await?; Ok(Json(json!({ "server": { "id": server.id, "name": server.name, "online": server.is_online(), "players_online": server.online_count, "players_max": server.max_players, }, "leaderboard": leaderboard, "totals": { "players": players, "playtime_secs": playtime.unwrap_or(0) }, "sort": sort, }))) } /// Public global leaderboard aggregated across all servers. pub async fn global_leaderboard( State(state): State, Query(q): Query, ) -> AppResult> { let sort = match q.sort.as_str() { "joins" | "deaths" | "player_kills" | "mob_kills" | "blocks_broken" | "blocks_placed" | "messages" | "last_seen" => q.sort.as_str(), _ => "playtime_secs", }; let limit = q.limit.clamp(1, 100); let leaderboard: Vec = sqlx::query_as(&format!( "SELECT uuid, name, SUM(playtime_secs) as playtime_secs, SUM(joins) as joins, SUM(deaths) as deaths, SUM(player_kills) as player_kills, SUM(mob_kills) as mob_kills, SUM(blocks_broken) as blocks_broken, SUM(blocks_placed) as blocks_placed, SUM(messages) as messages, MIN(first_seen) as first_seen, MAX(last_seen) as last_seen FROM player_stats GROUP BY uuid ORDER BY {sort} DESC LIMIT {limit}" )) .fetch_all(&state.db) .await?; let (players, playtime): (i64, Option) = sqlx::query_as("SELECT COUNT(DISTINCT uuid), SUM(playtime_secs) FROM player_stats") .fetch_one(&state.db) .await?; Ok(Json(json!({ "leaderboard": leaderboard, "totals": { "players": players, "playtime_secs": playtime.unwrap_or(0) }, "sort": sort, }))) } /// Player's personal stats across servers (authenticated endpoint for launcher). pub async fn account_player_stats( State(state): State, AuthUser(user): AuthUser, ) -> AppResult> { let rows: Vec = sqlx::query_as( "SELECT s.*, g.name AS server_name FROM player_stats s JOIN game_servers g ON g.id = s.server_id WHERE s.uuid = ? ORDER BY s.last_seen DESC", ) .bind(&user.uuid) .fetch_all(&state.db) .await?; let total: Option = sqlx::query_as( "SELECT uuid, name, SUM(playtime_secs) as playtime_secs, SUM(joins) as joins, SUM(deaths) as deaths, SUM(player_kills) as player_kills, SUM(mob_kills) as mob_kills, SUM(blocks_broken) as blocks_broken, SUM(blocks_placed) as blocks_placed, SUM(messages) as messages, MIN(first_seen) as first_seen, MAX(last_seen) as last_seen FROM player_stats WHERE uuid = ? GROUP BY uuid" ) .bind(&user.uuid) .fetch_optional(&state.db) .await?; let events: Vec = sqlx::query_as("SELECT * FROM server_events WHERE uuid = ? ORDER BY id DESC LIMIT 20") .bind(&user.uuid) .fetch_all(&state.db) .await?; Ok(Json(json!({ "username": user.username, "uuid": user.uuid, "total": total, "servers": rows, "events": events, }))) } /// Live summary for the dashboard. pub async fn live_summary(state: &AppState) -> AppResult { let servers: Vec = sqlx::query_as("SELECT * FROM game_servers ORDER BY name COLLATE NOCASE").fetch_all(&state.db).await?; let online_now: i64 = servers.iter().filter(|s| s.is_online()).map(|s| s.online_count).sum(); Ok(json!({ "online_now": online_now, "servers": servers.into_iter().map(view).collect::>(), })) } #[cfg(test)] mod tests { use super::*; #[test] fn test_ips_match() { // Exact match assert!(ips_match("1.2.3.4", "1.2.3.4")); assert!(ips_match("203.0.113.9", "203.0.113.9")); // Loopback IPv4 & IPv6 assert!(ips_match("127.0.0.1", "::1")); assert!(ips_match("::1", "127.0.0.1")); assert!(ips_match("127.0.0.1", "127.0.0.1")); // IPv4-mapped IPv6 assert!(ips_match("::ffff:192.168.1.10", "192.168.1.10")); assert!(ips_match("192.168.1.10", "::ffff:192.168.1.10")); // Docker bridge / private gateway recorded assert!(ips_match("172.18.0.1", "192.168.1.50")); assert!(ips_match("10.0.0.1", "10.0.0.2")); // Subnet /24 match assert!(ips_match("203.0.113.5", "203.0.113.9")); // Different public networks do not match assert!(!ips_match("198.51.100.1", "203.0.113.9")); assert!(!ips_match("8.8.8.8", "1.1.1.1")); } }