From a50963f9e059f098e6459fa9892263ac88db78d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:04:11 +0000 Subject: [PATCH] Panel: game server integration API Game servers (Paper plugin, Fabric/Forge mods) authenticate with a per-server token and report to /api/server/v1: - hello: records software/version, returns the auth server URL - login: allow/deny with a message (account status + reason, access all/members/groups, optional "must join through the launcher" check matching the player's recent launch IP) - sync: online players, TPS, stat deltas and events; answers with players to kick when their account was disabled meanwhile Admins create servers (token shown once, stored hashed), regenerate tokens, and read leaderboards, events and per-player activity. The dashboard stats include who's online right now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS --- panel/server/src/routes/admin.rs | 1 + panel/server/src/routes/mod.rs | 13 + panel/server/src/routes/servers.rs | 655 +++++++++++++++++++++++++++++ panel/server/tests/servers.rs | 216 ++++++++++ 4 files changed, 885 insertions(+) create mode 100644 panel/server/src/routes/servers.rs create mode 100644 panel/server/tests/servers.rs diff --git a/panel/server/src/routes/admin.rs b/panel/server/src/routes/admin.rs index 18d575b..d01320e 100644 --- a/panel/server/src/routes/admin.rs +++ b/panel/server/src/routes/admin.rs @@ -57,6 +57,7 @@ pub async fn stats(_: AdminUser, State(state): State) -> AppResult>(), "recent": recent.into_iter().map(|(i, u, t)| json!({"instance_id": i, "username": u, "at": t})).collect::>(), "launcher_download_url": settings.launcher_download_url, + "live": crate::routes::servers::live_summary(&state).await?, "version": env!("CARGO_PKG_VERSION"), }))) } diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index 5cd7206..d54c225 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -2,6 +2,7 @@ pub mod account; pub mod admin; pub mod meta; pub mod public; +pub mod servers; use crate::state::AppState; use axum::extract::DefaultBodyLimit; @@ -48,6 +49,10 @@ pub fn api(state: &AppState) -> Router { .route("/capes", get(admin::list_capes).post(admin::create_cape)) .route("/capes/{id}", axum::routing::put(admin::update_cape).delete(admin::delete_cape)) .route("/auth-server", get(admin::auth_server_info)) + .route("/servers", get(servers::list).post(servers::create)) + .route("/servers/{id}", get(servers::detail).put(servers::update).delete(servers::remove)) + .route("/servers/{id}/token", post(servers::regenerate_token)) + .route("/users/{id}/activity", get(servers::user_activity)) .route("/meta/minecraft", get(meta::minecraft)) .route("/meta/loaders/{loader}", get(meta::loaders)) .route("/modrinth/search", get(meta::modrinth_search)) @@ -56,8 +61,16 @@ pub fn api(state: &AppState) -> Router { .route("/curseforge/mod/{id}/files", get(meta::curseforge_files)) .layer(DefaultBodyLimit::max(upload_limit)); + // Called by the SCOPENET Paper plugin and Fabric/Forge mods. + let game = Router::new() + .route("/hello", post(servers::hello)) + .route("/login", post(servers::login)) + .route("/sync", post(servers::sync)) + .layer(DefaultBodyLimit::max(2 * 1024 * 1024)); + Router::new() .nest("/api/v1", launcher) + .nest("/api/server/v1", game) .nest("/api/admin", admin) .merge(crate::yggdrasil::routes().layer(DefaultBodyLimit::max(4 * 1024 * 1024))) } diff --git a/panel/server/src/routes/servers.rs b/panel/server/src/routes/servers.rs new file mode 100644 index 0000000..5c3da80 --- /dev/null +++ b/panel/server/src/routes/servers.rs @@ -0,0 +1,655 @@ +//! 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, 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; + let user = match user_by_uuid(&state, &req.uuid).await? { + Some(u) => Some(u), + // Offline-mode servers may send a UUID we don't know (e.g. a + // Floodgate player) — fall back to the name. + None => auth::find_user_by_name(&state, &req.name).await?, + }; + Ok(Json(check_login(&state, &server, user.as_ref(), req.ip.as_deref(), &brand).await?)) +} + +async fn check_login( + state: &AppState, + server: &ServerRow, + user: Option<&UserRow>, + ip: Option<&str>, + brand: &str, +) -> AppResult { + let Some(user) = user else { + return Ok(if server.access == "all" { + LoginVerdict { allowed: true, message: None, account: None } + } else { + 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: bool = match ip { + Some(ip) => { + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM launcher_sessions WHERE user_id = ? AND ip = ? AND created_at >= ?)") + .bind(user.id) + .bind(ip.trim()) + .bind(&since) + .fetch_one(&state.db) + .await? + } + None => true, + }; + 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 { + 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?; + + 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 { + 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().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 }; + if user.status != "active" { + let verdict = check_login(&state, &server, Some(&user), None, &brand).await?; + 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 { + server_id: i64, + 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, + first_seen: String, + last_seen: String, +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct EventRow { + id: i64, + server_id: i64, + uuid: Option, + name: Option, + kind: String, + detail: Option, + 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)] + stats: StatRow, + server_name: String, +} + +/// 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::>(), + })) +} diff --git a/panel/server/tests/servers.rs b/panel/server/tests/servers.rs new file mode 100644 index 0000000..00c1987 --- /dev/null +++ b/panel/server/tests/servers.rs @@ -0,0 +1,216 @@ +//! Game server integration API: tokens, login checks, stats sync. + +mod common; +use common::*; + +async fn admin_and_player(t: &TestApp) -> (String, i64) { + let admin = t.login("admin", "supersecret").await; + let (s, v) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + (admin, v["id"].as_i64().unwrap()) +} + +async fn create_server(t: &TestApp, admin: &str, body: Value) -> (i64, String) { + let (s, v) = t.call("POST", "/api/admin/servers", Some(admin), Some(body)).await; + assert_eq!(s, StatusCode::OK, "{v}"); + let token = v["token"].as_str().unwrap().to_string(); + assert!(token.starts_with("sn_")); + assert_eq!(v["server"]["token_hint"], token[token.len() - 4..]); + assert!(v["server"].get("token_hash").is_none()); + (v["server"]["id"].as_i64().unwrap(), token) +} + +fn steve() -> String { + scopenet_shared::offline_uuid("Steve") +} + +#[tokio::test] +async fn tokens_authenticate_servers() { + let t = setup().await; + let (admin, _) = admin_and_player(&t).await; + let (id, token) = create_server(&t, &admin, json!({"name": "Survival"})).await; + + let (s, _) = t.call("POST", "/api/server/v1/hello", None, Some(json!({}))).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + let (s, _) = t.call("POST", "/api/server/v1/hello", Some("sn_nope"), Some(json!({}))).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + + let (s, v) = t + .call( + "POST", + "/api/server/v1/hello", + Some(&token), + Some(json!({"software": "Paper", "mc_version": "1.21.1", "plugin_version": "0.1.0", "online_mode": true, "max_players": 50})), + ) + .await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["server_id"], id); + assert_eq!(v["yggdrasil_url"], "https://panel.test/api/yggdrasil"); + + let (_, list) = t.call("GET", "/api/admin/servers", Some(&admin), None).await; + assert_eq!(list[0]["software"], "Paper"); + assert_eq!(list[0]["online"], true); + assert_eq!(list[0]["max_players"], 50); + + // Regenerating invalidates the old token. + let (s, v) = t.call("POST", &format!("/api/admin/servers/{id}/token"), Some(&admin), None).await; + assert_eq!(s, StatusCode::OK); + let fresh = v["token"].as_str().unwrap(); + let (s, _) = t.call("POST", "/api/server/v1/hello", Some(&token), Some(json!({}))).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + let (s, _) = t.call("POST", "/api/server/v1/hello", Some(fresh), Some(json!({}))).await; + assert_eq!(s, StatusCode::OK); + + // Players can't manage servers. + let player = t.login("Steve", "password123").await; + let (s, _) = t.call("GET", "/api/admin/servers", Some(&player), None).await; + assert_eq!(s, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn login_rules() { + let t = setup().await; + let (admin, steve_id) = admin_and_player(&t).await; + let login = |token: String, uuid: String, name: &'static str, ip: Option<&'static str>| { + let t = &t; + async move { t.call("POST", "/api/server/v1/login", Some(&token), Some(json!({"uuid": uuid, "name": name, "ip": ip}))).await.1 } + }; + + // "all": unknown players are fine, accounts are identified. + let (_, open) = create_server(&t, &admin, json!({"name": "Open"})).await; + let v = login(open.clone(), "00000000-0000-0000-0000-000000000001".into(), "Stranger", None).await; + assert_eq!(v["allowed"], true, "{v}"); + let v = login(open.clone(), steve().replace('-', ""), "Steve", None).await; + assert_eq!(v["allowed"], true); + assert_eq!(v["account"]["username"], "Steve"); + + // "members": unknown players are refused. + let (_, members) = create_server(&t, &admin, json!({"name": "Members", "access": "members"})).await; + let v = login(members.clone(), "00000000-0000-0000-0000-000000000001".into(), "Stranger", None).await; + assert_eq!(v["allowed"], false); + assert!(v["message"].as_str().unwrap().contains("account")); + + // "groups": only listed groups (admins always pass). + let (s, v) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "Staff", "access": "groups"}))).await; + assert_eq!(s, StatusCode::BAD_REQUEST, "{v}"); + let (_, staff) = create_server(&t, &admin, json!({"name": "Staff", "access": "groups", "allowed_groups": ["builders"]})).await; + let v = login(staff.clone(), steve(), "Steve", None).await; + assert_eq!(v["allowed"], false); + let admin_uuid = scopenet_shared::offline_uuid("admin"); + let v = login(staff.clone(), admin_uuid, "admin", None).await; + assert_eq!(v["allowed"], true, "{v}"); + t.call("POST", "/api/admin/groups", Some(&admin), Some(json!({"name": "builders"}))).await; + let (s, v) = t.call("PATCH", &format!("/api/admin/users/{steve_id}"), Some(&admin), Some(json!({"groups": ["builders"]}))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + let v = login(staff.clone(), steve(), "Steve", None).await; + assert_eq!(v["allowed"], true, "{v}"); + + // Require launcher: a launch from the same IP is needed. + let (_, strict) = create_server(&t, &admin, json!({"name": "Strict", "require_launcher": true})).await; + let v = login(strict.clone(), steve(), "Steve", Some("203.0.113.9")).await; + assert_eq!(v["allowed"], false); + assert!(v["message"].as_str().unwrap().contains("launcher")); + let player = t.login("Steve", "password123").await; + let req = Request::builder() + .method("POST") + .uri("/api/v1/launcher/events") + .header("authorization", format!("Bearer {player}")) + .header("content-type", "application/json") + .header("x-forwarded-for", "203.0.113.9") + .body(Body::from(json!({"kind": "launch", "instance_id": "survival"}).to_string())) + .unwrap(); + let (s, v) = t.send(req).await; + assert_eq!(s, StatusCode::OK, "{v}"); + let v = login(strict.clone(), steve(), "Steve", Some("203.0.113.9")).await; + assert_eq!(v["allowed"], true, "{v}"); + let v = login(strict.clone(), steve(), "Steve", Some("198.51.100.1")).await; + assert_eq!(v["allowed"], false); + + // Disabled accounts see the reason. + t.call( + "PATCH", + &format!("/api/admin/users/{steve_id}"), + Some(&admin), + Some(json!({"status": "disabled", "status_reason": "Griefing"})), + ) + .await; + let v = login(open, steve(), "Steve", None).await; + assert_eq!(v["allowed"], false); + assert!(v["message"].as_str().unwrap().contains("Griefing")); +} + +#[tokio::test] +async fn sync_tracks_players_stats_and_kicks() { + let t = setup().await; + let (admin, steve_id) = admin_and_player(&t).await; + let (id, token) = create_server(&t, &admin, json!({"name": "Survival"})).await; + t.call("POST", "/api/server/v1/hello", Some(&token), Some(json!({"max_players": 20}))).await; + + let sync = json!({ + "tps": 19.8, + "online": [{"uuid": steve().replace('-', ""), "name": "Steve"}], + "stats": [{"uuid": steve(), "name": "Steve", "playtime_secs": 30, "joins": 1, "blocks_broken": 12, "deaths": 1}], + "events": [ + {"uuid": steve(), "name": "Steve", "kind": "join"}, + {"uuid": steve(), "name": "Steve", "kind": "death", "detail": "Steve fell from a high place"} + ] + }); + let (s, v) = t.call("POST", "/api/server/v1/sync", Some(&token), Some(sync)).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["kick"], json!([])); + // Deltas add up. + let (s, _) = t + .call( + "POST", + "/api/server/v1/sync", + Some(&token), + Some(json!({"tps": 20.0, "online": [{"uuid": steve(), "name": "Steve"}], "stats": [{"uuid": steve(), "name": "Steve", "playtime_secs": 30, "blocks_broken": 3}]})), + ) + .await; + assert_eq!(s, StatusCode::OK); + + let (s, d) = t.call("GET", &format!("/api/admin/servers/{id}"), Some(&admin), None).await; + assert_eq!(s, StatusCode::OK, "{d}"); + assert_eq!(d["server"]["players"], 1); + assert_eq!(d["server"]["tps"], 20.0); + assert_eq!(d["online_players"][0]["name"], "Steve"); + let row = &d["leaderboard"][0]; + assert_eq!(row["playtime_secs"], 60); + assert_eq!(row["blocks_broken"], 15); + assert_eq!(row["joins"], 1); + assert_eq!(d["events"][0]["kind"], "death"); + assert_eq!(d["events"][1]["kind"], "join"); + assert_eq!(d["totals"]["players"], 1); + + let (_, dash) = t.call("GET", "/api/admin/stats", Some(&admin), None).await; + assert_eq!(dash["live"]["online_now"], 1); + + let (_, users) = t.call("GET", "/api/admin/users", Some(&admin), None).await; + let s_view = users.as_array().unwrap().iter().find(|u| u["username"] == "Steve").unwrap(); + assert_eq!(s_view["playtime_secs"], 60); + let (s, act) = t.call("GET", &format!("/api/admin/users/{steve_id}/activity"), Some(&admin), None).await; + assert_eq!(s, StatusCode::OK, "{act}"); + assert_eq!(act["servers"][0]["server_name"], "Survival"); + assert_eq!(act["online_on"][0]["name"], "Survival"); + + // Disabling an online player asks the server to kick them. + t.call( + "PATCH", + &format!("/api/admin/users/{steve_id}"), + Some(&admin), + Some(json!({"status": "disabled", "status_reason": "Cheating"})), + ) + .await; + let (_, v) = t.call("POST", "/api/server/v1/sync", Some(&token), Some(json!({"online": [{"uuid": steve(), "name": "Steve"}]}))).await; + assert_eq!(v["kick"][0]["uuid"], steve()); + assert!(v["kick"][0]["message"].as_str().unwrap().contains("Cheating")); + + // Leaving empties the online list; deleting the server removes its data. + t.call("POST", "/api/server/v1/sync", Some(&token), Some(json!({"online": []}))).await; + let (_, d) = t.call("GET", &format!("/api/admin/servers/{id}"), Some(&admin), None).await; + assert_eq!(d["online_players"], json!([])); + let (s, _) = t.call("DELETE", &format!("/api/admin/servers/{id}"), Some(&admin), None).await; + assert_eq!(s, StatusCode::OK); + let (s, _) = t.call("POST", "/api/server/v1/sync", Some(&token), Some(json!({}))).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); +}