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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
4 files changed
+885
No files matched your search
@@ -57,6 +57,7 @@ pub async fn stats(_: AdminUser, State(state): State<AppState>) -> AppResult<Jso
|
|||||||
"top_instances": top.into_iter().map(|(id, c)| json!({"instance_id": id, "launches": c})).collect::<Vec<_>>(),
|
"top_instances": top.into_iter().map(|(id, c)| json!({"instance_id": id, "launches": c})).collect::<Vec<_>>(),
|
||||||
"recent": recent.into_iter().map(|(i, u, t)| json!({"instance_id": i, "username": u, "at": t})).collect::<Vec<_>>(),
|
"recent": recent.into_iter().map(|(i, u, t)| json!({"instance_id": i, "username": u, "at": t})).collect::<Vec<_>>(),
|
||||||
"launcher_download_url": settings.launcher_download_url,
|
"launcher_download_url": settings.launcher_download_url,
|
||||||
|
"live": crate::routes::servers::live_summary(&state).await?,
|
||||||
"version": env!("CARGO_PKG_VERSION"),
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod account;
|
|||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
pub mod public;
|
pub mod public;
|
||||||
|
pub mod servers;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use axum::extract::DefaultBodyLimit;
|
use axum::extract::DefaultBodyLimit;
|
||||||
@@ -48,6 +49,10 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
|||||||
.route("/capes", get(admin::list_capes).post(admin::create_cape))
|
.route("/capes", get(admin::list_capes).post(admin::create_cape))
|
||||||
.route("/capes/{id}", axum::routing::put(admin::update_cape).delete(admin::delete_cape))
|
.route("/capes/{id}", axum::routing::put(admin::update_cape).delete(admin::delete_cape))
|
||||||
.route("/auth-server", get(admin::auth_server_info))
|
.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/minecraft", get(meta::minecraft))
|
||||||
.route("/meta/loaders/{loader}", get(meta::loaders))
|
.route("/meta/loaders/{loader}", get(meta::loaders))
|
||||||
.route("/modrinth/search", get(meta::modrinth_search))
|
.route("/modrinth/search", get(meta::modrinth_search))
|
||||||
@@ -56,8 +61,16 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
|||||||
.route("/curseforge/mod/{id}/files", get(meta::curseforge_files))
|
.route("/curseforge/mod/{id}/files", get(meta::curseforge_files))
|
||||||
.layer(DefaultBodyLimit::max(upload_limit));
|
.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()
|
Router::new()
|
||||||
.nest("/api/v1", launcher)
|
.nest("/api/v1", launcher)
|
||||||
|
.nest("/api/server/v1", game)
|
||||||
.nest("/api/admin", admin)
|
.nest("/api/admin", admin)
|
||||||
.merge(crate::yggdrasil::routes().layer(DefaultBodyLimit::max(4 * 1024 * 1024)))
|
.merge(crate::yggdrasil::routes().layer(DefaultBodyLimit::max(4 * 1024 * 1024)))
|
||||||
}
|
}
|
||||||
@@ -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<chrono::Utc>) -> 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<String>,
|
||||||
|
pub mc_version: Option<String>,
|
||||||
|
pub plugin_version: Option<String>,
|
||||||
|
pub online_mode: Option<bool>,
|
||||||
|
pub max_players: i64,
|
||||||
|
pub online_count: i64,
|
||||||
|
pub tps: Option<f64>,
|
||||||
|
pub last_seen: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerRow {
|
||||||
|
fn groups(&self) -> Vec<String> {
|
||||||
|
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<String>,
|
||||||
|
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<AppState> for GameServer {
|
||||||
|
type Rejection = AppError;
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||||
|
let token = auth::bearer(parts).ok_or_else(|| AppError::unauthorized("missing server token"))?;
|
||||||
|
let row: Option<ServerRow> = 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<String>,
|
||||||
|
mc_version: Option<String>,
|
||||||
|
plugin_version: Option<String>,
|
||||||
|
online_mode: Option<bool>,
|
||||||
|
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<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(h): Json<Hello>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug)]
|
||||||
|
pub struct LoginVerdict {
|
||||||
|
allowed: bool,
|
||||||
|
/// Shown to the player on the disconnect screen when refused.
|
||||||
|
message: Option<String>,
|
||||||
|
/// The panel account behind the player, if any.
|
||||||
|
account: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginVerdict {
|
||||||
|
fn deny(message: impl Into<String>) -> 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<AppState>,
|
||||||
|
Json(req): Json<LoginCheck>,
|
||||||
|
) -> AppResult<Json<LoginVerdict>> {
|
||||||
|
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<LoginVerdict> {
|
||||||
|
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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
kind: String,
|
||||||
|
#[serde(default)]
|
||||||
|
detail: Option<String>,
|
||||||
|
/// Epoch millis when it happened (defaults to now).
|
||||||
|
#[serde(default)]
|
||||||
|
at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Sync {
|
||||||
|
tps: Option<f64>,
|
||||||
|
online: Vec<OnlinePlayer>,
|
||||||
|
stats: Vec<StatDelta>,
|
||||||
|
events: Vec<GameEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<AppState>, Json(s): Json<Sync>) -> AppResult<Json<Value>> {
|
||||||
|
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::<Vec<_>>()).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<AppState>) -> AppResult<Json<Vec<ServerView>>> {
|
||||||
|
let rows: Vec<ServerRow> = 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<String>,
|
||||||
|
#[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<ServerRow> {
|
||||||
|
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<AppState>, Json(input): Json<ServerInput>) -> AppResult<Json<Value>> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(input): Json<ServerInput>,
|
||||||
|
) -> AppResult<Json<ServerView>> {
|
||||||
|
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<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
|
||||||
|
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<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
|
||||||
|
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<String>,
|
||||||
|
name: Option<String>,
|
||||||
|
kind: String,
|
||||||
|
detail: Option<String>,
|
||||||
|
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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Query(q): Query<DetailQuery>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let server = get_server(&state, id).await?;
|
||||||
|
let online: Vec<OnlineRow> = 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<StatRow> =
|
||||||
|
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<EventRow> =
|
||||||
|
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<i64>) =
|
||||||
|
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<AppState>, Path(user_id): Path<i64>) -> AppResult<Json<Value>> {
|
||||||
|
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<StatRowNamed> = 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<EventRow> =
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Value> {
|
||||||
|
let servers: Vec<ServerRow> = 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::<Vec<_>>(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user