diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs index 3a676cc..ca07e70 100644 --- a/panel/server/src/db.rs +++ b/panel/server/src/db.rs @@ -680,6 +680,33 @@ const MIGRATIONS: &[&str] = &[ r#" ALTER TABLE game_servers ADD COLUMN economy_group TEXT NOT NULL DEFAULT ''; "#, + // BlueMap: a map address per server, in-game actions triggered from the map, and guild invitations. + r#" + ALTER TABLE game_servers ADD COLUMN map_address TEXT NOT NULL DEFAULT ''; + CREATE TABLE server_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + from_uuid TEXT NOT NULL DEFAULT '', + from_name TEXT NOT NULL DEFAULT '', + to_uuid TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + taken_at TEXT + ); + CREATE INDEX server_actions_pending ON server_actions(server_id, taken_at); + CREATE INDEX server_actions_sender ON server_actions(from_uuid, created_at); + CREATE TABLE guild_invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE, + inviter_uuid TEXT NOT NULL, + inviter_name TEXT NOT NULL, + target_uuid TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL + ); + CREATE INDEX guild_invites_target ON guild_invites(target_uuid, status); + "#, ]; pub async fn connect(data_dir: &Path) -> Result { diff --git a/panel/server/src/purge.rs b/panel/server/src/purge.rs index 0427822..ae5081f 100644 --- a/panel/server/src/purge.rs +++ b/panel/server/src/purge.rs @@ -72,6 +72,8 @@ pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult = sqlx::query_as( - "SELECT gc.dimension, gc.chunk_x, gc.chunk_z, gc.guild_id, g.name, g.tag + let rows: Vec<(String, i32, i32, String, String, String, Option)> = sqlx::query_as( + "SELECT gc.dimension, gc.chunk_x, gc.chunk_z, gc.guild_id, g.name, g.tag, g.icon_url FROM guild_claims gc JOIN guilds g ON g.id = gc.guild_id WHERE gc.server_id = ?", ) .bind(server.id) @@ -950,9 +950,9 @@ pub async fn server_claim_index( let mut guilds: Vec = Vec::new(); let mut index: std::collections::HashMap = std::collections::HashMap::new(); let mut claims: Vec = Vec::with_capacity(rows.len()); - for (dimension, x, z, guild_id, name, tag) in rows { + for (dimension, x, z, guild_id, name, tag, icon) in rows { let i = *index.entry(guild_id.clone()).or_insert_with(|| { - guilds.push(json!({ "id": guild_id, "name": name, "tag": tag })); + guilds.push(json!({ "id": guild_id, "name": name, "tag": tag, "icon_url": icon.unwrap_or_default() })); guilds.len() - 1 }); claims.push(json!([dimension, x, z, i])); diff --git a/panel/server/src/routes/map.rs b/panel/server/src/routes/map.rs new file mode 100644 index 0000000..2ab8571 --- /dev/null +++ b/panel/server/src/routes/map.rs @@ -0,0 +1,300 @@ +//! Things you can do from the map: ask a player to teleport, message them in game, and invite them to your guild. +//! +//! Teleports and messages are queued for the game server (`server_actions`), which collects them every few seconds +//! and carries them out with its normal rules. Guild invitations are real invitations: the other player accepts +//! or declines, in the launcher or in game. + +use crate::auth::AuthUser; +use crate::error::{AppError, AppResult}; +use crate::routes::servers::{get_server, GameServer}; +use crate::state::AppState; +use axum::extract::{Path, State}; +use axum::Json; +use serde::Deserialize; +use serde_json::{json, Value}; +use sqlx::SqliteConnection; + +const ACTION_BURST: i64 = 6; +const ACTION_WINDOW_SECS: i64 = 30; + +fn ago(secs: i64) -> String { + (chrono::Utc::now() - chrono::Duration::seconds(secs)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +async fn online_on(conn: &mut SqliteConnection, server_id: i64, uuid: &str) -> AppResult { + Ok(sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE server_id = ? AND uuid = ?)").bind(server_id).bind(uuid).fetch_one(&mut *conn).await?) +} + +/// Keep only printable characters, collapse whitespace, and cut to `max`. +fn clean(text: &str, max: usize) -> String { + text.chars().filter(|c| c.is_whitespace() || !c.is_control()).map(|c| if c.is_whitespace() { ' ' } else { c }).collect::().split_whitespace().collect::>().join(" ").chars().take(max).collect() +} + +#[derive(Deserialize)] +pub struct MapAction { + /// `tpa` or `message`. + pub action: String, + pub target_uuid: String, + #[serde(default)] + pub text: String, +} + +/// `POST /servers/{id}/map/actions`: a signed-in player acts on another player they clicked on the map. +pub async fn map_action(auth: AuthUser, Path(server_id): Path, State(state): State, Json(a): Json) -> AppResult> { + get_server(&state, server_id).await?; + if a.target_uuid == auth.uuid { + return Err(AppError::bad_request("That's you")); + } + let mut conn = state.db.acquire().await?; + let active: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ? AND status = 'active')").bind(&a.target_uuid).fetch_one(&mut *conn).await?; + if !active { + return Err(AppError::not_found("Player not found")); + } + let recent: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM server_actions WHERE from_uuid = ? AND created_at > ?") + .bind(&auth.uuid) + .bind(ago(ACTION_WINDOW_SECS)) + .fetch_one(&mut *conn) + .await?; + if recent >= ACTION_BURST { + return Err(AppError::new(axum::http::StatusCode::TOO_MANY_REQUESTS, "Slow down a little and try again")); + } + if !online_on(&mut conn, server_id, &a.target_uuid).await? { + return Err(AppError::bad_request("They aren't online on this server right now")); + } + let text = match a.action.as_str() { + "tpa" => { + if !online_on(&mut conn, server_id, &auth.uuid).await? { + return Err(AppError::bad_request("Join the server first: a teleport request needs you in game")); + } + String::new() + } + "message" => { + let text = clean(&a.text, 200); + if text.is_empty() { + return Err(AppError::bad_request("Write a message first")); + } + text + } + _ => return Err(AppError::bad_request("unknown action")), + }; + sqlx::query("INSERT INTO server_actions (server_id, kind, from_uuid, from_name, to_uuid, text, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)") + .bind(server_id) + .bind(&a.action) + .bind(&auth.uuid) + .bind(&auth.username) + .bind(&a.target_uuid) + .bind(&text) + .bind(crate::db::now()) + .execute(&mut *conn) + .await?; + Ok(Json(json!({ "ok": true }))) +} + +/// `POST /api/server/v1/actions/poll`: the game server collects what is waiting for it (each action is handed out once). +pub async fn poll_actions(GameServer(server): GameServer, State(state): State) -> AppResult> { + let mut tx = state.db.begin().await?; + // Stale ones (a server that was offline) are dropped rather than replayed minutes later. + sqlx::query("UPDATE server_actions SET taken_at = ? WHERE server_id = ? AND taken_at IS NULL AND created_at < ?") + .bind(crate::db::now()) + .bind(server.id) + .bind(ago(120)) + .execute(&mut *tx) + .await?; + let rows: Vec<(i64, String, String, String, String, String)> = sqlx::query_as( + "SELECT id, kind, from_uuid, from_name, to_uuid, text FROM server_actions WHERE server_id = ? AND taken_at IS NULL ORDER BY id LIMIT 50", + ) + .bind(server.id) + .fetch_all(&mut *tx) + .await?; + let now = crate::db::now(); + for (id, ..) in &rows { + sqlx::query("UPDATE server_actions SET taken_at = ? WHERE id = ?").bind(&now).bind(id).execute(&mut *tx).await?; + } + // Old handled rows are only useful for rate limiting. + sqlx::query("DELETE FROM server_actions WHERE taken_at IS NOT NULL AND created_at < ?").bind(ago(3600)).execute(&mut *tx).await?; + tx.commit().await?; + let actions: Vec = rows + .into_iter() + .map(|(id, kind, from_uuid, from_name, to_uuid, text)| json!({ "id": id, "kind": kind, "from_uuid": from_uuid, "from_name": from_name, "to_uuid": to_uuid, "text": text })) + .collect(); + Ok(Json(json!({ "actions": actions }))) +} + +/// Queue a line of chat for a player on every server where they are online. +pub async fn tell_player(conn: &mut SqliteConnection, to_uuid: &str, text: &str) -> AppResult<()> { + let servers: Vec = sqlx::query_scalar("SELECT server_id FROM server_online WHERE uuid = ?").bind(to_uuid).fetch_all(&mut *conn).await?; + for id in servers { + sqlx::query("INSERT INTO server_actions (server_id, kind, to_uuid, text, created_at) VALUES (?, 'notify', ?, ?, ?)") + .bind(id) + .bind(to_uuid) + .bind(text) + .bind(crate::db::now()) + .execute(&mut *conn) + .await?; + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Guild invitations +// --------------------------------------------------------------------------- + +/// Invite `target_uuid` to `guild_id` on behalf of `inviter_uuid` (who must lead or officiate it). +pub async fn create_invite(state: &AppState, inviter_uuid: &str, inviter_name: &str, guild_id: &str, target_uuid: &str) -> AppResult { + if inviter_uuid == target_uuid { + return Err(AppError::bad_request("You can't invite yourself")); + } + let mut conn = state.db.acquire().await?; + let role: Option = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?").bind(guild_id).bind(inviter_uuid).fetch_optional(&mut *conn).await?; + if !role.as_deref().is_some_and(|r| r == "leader" || r == "officer") { + return Err(AppError::forbidden("Only guild leaders and officers can invite players")); + } + let target: Option = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ? AND status = 'active'").bind(target_uuid).fetch_optional(&mut *conn).await?; + let target_name = target.ok_or_else(|| AppError::not_found("Player not found"))?; + let (name, tag, instance): (String, String, String) = + sqlx::query_as("SELECT name, tag, instance_id FROM guilds WHERE id = ?").bind(guild_id).fetch_one(&mut *conn).await?; + let taken: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?)", + ) + .bind(target_uuid) + .bind(&instance) + .fetch_one(&mut *conn) + .await?; + if taken { + return Err(AppError::bad_request(format!("{target_name} is already in a guild here"))); + } + let recent: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_invites WHERE inviter_uuid = ? AND created_at > ?") + .bind(inviter_uuid) + .bind(ago(60)) + .fetch_one(&mut *conn) + .await?; + if recent >= 10 { + return Err(AppError::new(axum::http::StatusCode::TOO_MANY_REQUESTS, "Too many invitations; wait a minute")); + } + // One open invitation per guild and player. + sqlx::query("DELETE FROM guild_invites WHERE guild_id = ? AND target_uuid = ? AND status = 'pending'").bind(guild_id).bind(target_uuid).execute(&mut *conn).await?; + let id: i64 = sqlx::query_scalar("INSERT INTO guild_invites (guild_id, inviter_uuid, inviter_name, target_uuid, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id") + .bind(guild_id) + .bind(inviter_uuid) + .bind(inviter_name) + .bind(target_uuid) + .bind(crate::db::now()) + .fetch_one(&mut *conn) + .await?; + tell_player(&mut conn, target_uuid, &format!("\u{a7}e{inviter_name} \u{a7}ainvited you to join \u{a7}6[{tag}] {name}\u{a7}a. \u{a7}7Type \u{a7}e/guild accept \u{a7}7or \u{a7}c/guild decline\u{a7}7, or answer in the launcher.")).await?; + Ok(json!({ "ok": true, "id": id, "player": target_name })) +} + +#[derive(Deserialize)] +pub struct InvitePayload { + pub uuid: String, +} + +pub async fn send_invite(auth: AuthUser, Path(guild_id): Path, State(state): State, Json(p): Json) -> AppResult> { + Ok(Json(create_invite(&state, &auth.uuid, &auth.username, &guild_id, &p.uuid).await?)) +} + +fn invite_json(row: (i64, String, String, String, String, String, String)) -> Value { + let (id, guild_id, name, tag, icon, inviter, at) = row; + json!({ "id": id, "guild_id": guild_id, "guild_name": name, "guild_tag": tag, "icon_url": icon, "inviter": inviter, "created_at": at }) +} + +async fn pending_for(state: &AppState, uuid: &str) -> AppResult> { + let rows: Vec<(i64, String, String, String, String, String, String)> = sqlx::query_as( + "SELECT i.id, i.guild_id, g.name, g.tag, COALESCE(g.icon_url, ''), i.inviter_name, i.created_at + FROM guild_invites i JOIN guilds g ON g.id = i.guild_id WHERE i.target_uuid = ? AND i.status = 'pending' ORDER BY i.id DESC LIMIT 20", + ) + .bind(uuid) + .fetch_all(&state.db) + .await?; + Ok(rows.into_iter().map(invite_json).collect()) +} + +pub async fn my_invites(auth: AuthUser, State(state): State) -> AppResult> { + Ok(Json(json!(pending_for(&state, &auth.uuid).await?))) +} + +/// Accept or decline. `invite` is an id, or empty to take the newest open one. +pub async fn respond(state: &AppState, uuid: &str, name: &str, invite: Option, tag: Option<&str>, accept: bool) -> AppResult { + let mut tx = state.db.begin().await?; + let row: Option<(i64, String)> = match (invite, tag) { + (Some(id), _) => sqlx::query_as("SELECT id, guild_id FROM guild_invites WHERE id = ? AND target_uuid = ? AND status = 'pending'").bind(id).bind(uuid).fetch_optional(&mut *tx).await?, + (None, Some(tag)) => sqlx::query_as( + "SELECT i.id, i.guild_id FROM guild_invites i JOIN guilds g ON g.id = i.guild_id + WHERE i.target_uuid = ? AND i.status = 'pending' AND g.tag = ? COLLATE NOCASE ORDER BY i.id DESC LIMIT 1", + ) + .bind(uuid) + .bind(tag) + .fetch_optional(&mut *tx) + .await?, + (None, None) => sqlx::query_as("SELECT id, guild_id FROM guild_invites WHERE target_uuid = ? AND status = 'pending' ORDER BY id DESC LIMIT 1").bind(uuid).fetch_optional(&mut *tx).await?, + }; + let Some((id, guild_id)) = row else { return Err(AppError::not_found("You have no open guild invitation")) }; + sqlx::query("UPDATE guild_invites SET status = ? WHERE id = ?").bind(if accept { "accepted" } else { "declined" }).bind(id).execute(&mut *tx).await?; + let (gname, gtag): (String, String) = sqlx::query_as("SELECT name, tag FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&mut *tx).await?; + if accept { + let res = sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, ?, 'member', ?)") + .bind(&guild_id) + .bind(uuid) + .bind(name) + .bind(crate::db::now()) + .execute(&mut *tx) + .await; + if res.is_err() { + // The database refuses a second guild on the same instance. + return Err(AppError::bad_request("You're already in a guild here. Leave it first.")); + } + sqlx::query("INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, 'ach_guild_initiate', ?)").bind(uuid).bind(crate::db::now()).execute(&mut *tx).await?; + } + tx.commit().await?; + Ok(json!({ "ok": true, "accepted": accept, "guild": gname, "tag": gtag })) +} + +pub async fn accept_invite(auth: AuthUser, Path(id): Path, State(state): State) -> AppResult> { + Ok(Json(respond(&state, &auth.uuid, &auth.username, Some(id), None, true).await?)) +} + +pub async fn decline_invite(auth: AuthUser, Path(id): Path, State(state): State) -> AppResult> { + Ok(Json(respond(&state, &auth.uuid, &auth.username, Some(id), None, false).await?)) +} + +// ---- the same, asked by a game server on a player's behalf --------------------------------- + +#[derive(Deserialize)] +pub struct ServerInvite { + /// The player asking (inviter, or the invited player when answering). + pub uuid: String, + #[serde(default)] + pub target: String, + #[serde(default)] + pub tag: String, + #[serde(default)] + pub accept: bool, +} + +pub async fn server_invite_send(GameServer(server): GameServer, State(state): State, Json(p): Json) -> AppResult> { + let inviter: Option = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?; + let inviter = inviter.ok_or_else(|| AppError::not_found("Account not found"))?; + let target: Option = sqlx::query_scalar("SELECT uuid FROM users WHERE username = ? COLLATE NOCASE").bind(p.target.trim()).fetch_optional(&state.db).await?; + let target = target.ok_or_else(|| AppError::not_found("No player with that name"))?; + let guild: Option = sqlx::query_scalar( + "SELECT g.id FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ? AND gm.role IN ('leader', 'officer')", + ) + .bind(&p.uuid) + .bind(&server.instance_id) + .fetch_optional(&state.db) + .await?; + let guild = guild.ok_or_else(|| AppError::forbidden("You must lead or officiate a guild to invite players"))?; + Ok(Json(create_invite(&state, &p.uuid, &inviter, &guild, &target).await?)) +} + +pub async fn server_invites(GameServer(_): GameServer, State(state): State, Json(p): Json) -> AppResult> { + Ok(Json(json!({ "invites": pending_for(&state, &p.uuid).await? }))) +} + +pub async fn server_invite_respond(GameServer(_): GameServer, State(state): State, Json(p): Json) -> AppResult> { + let name: Option = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?; + let name = name.ok_or_else(|| AppError::not_found("Account not found"))?; + let tag = if p.tag.trim().is_empty() { None } else { Some(p.tag.trim().to_string()) }; + Ok(Json(respond(&state, &p.uuid, &name, None, tag.as_deref(), p.accept).await?)) +} diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index b82975d..b94d1b6 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -11,6 +11,7 @@ pub mod discord; pub mod integrations; pub mod landing; pub mod livemap; +pub mod map; pub mod leveling; pub mod meta; pub mod progression; @@ -53,6 +54,11 @@ pub fn api(state: &AppState) -> Router { .route("/servers/public", get(servers::public_servers)) .route("/servers/{id}/leaderboard", get(servers::public_server_leaderboard)) .route("/servers/{id}/livemap", get(livemap::viewer_info)) + .route("/servers/{id}/map/actions", post(map::map_action)) + .route("/guilds/invites", get(map::my_invites)) + .route("/guilds/invites/{id}/accept", post(map::accept_invite)) + .route("/guilds/invites/{id}/decline", post(map::decline_invite)) + .route("/guilds/{id}/invites", post(map::send_invite)) .route("/leaderboard", get(servers::global_leaderboard)) .route("/landing", get(landing::public_landing)) .route("/launcher/authlib-injector.json", get(account::authlib_index)) @@ -195,6 +201,10 @@ pub fn api(state: &AppState) -> Router { .route("/guilds/create", post(guilds::server_create_guild)) .route("/guilds/leave", post(guilds::server_guild_leave)) .route("/integrations/report", post(integrations::server_report)) + .route("/actions/poll", post(map::poll_actions)) + .route("/guilds/invite/send", post(map::server_invite_send)) + .route("/guilds/invite/list", post(map::server_invites)) + .route("/guilds/invite/respond", post(map::server_invite_respond)) .route("/player/info", post(dev_api::player_info)) .route("/player/xp", post(dev_api::add_xp)) .route("/player/quest-objective", post(dev_api::quest_objective)) diff --git a/panel/server/src/routes/servers.rs b/panel/server/src/routes/servers.rs index e94dc07..b6c6af7 100644 --- a/panel/server/src/routes/servers.rs +++ b/panel/server/src/routes/servers.rs @@ -63,6 +63,9 @@ pub struct ServerRow { pub instance_id: String, pub map_url: String, pub live_map_enabled: bool, + /// Address of this server's BlueMap (https://map.example.com). Shown in the launcher when set. + #[sqlx(default)] + pub map_address: String, /// Servers with the same non-empty name share player balances and guild banks. #[sqlx(default)] pub economy_group: String, @@ -869,6 +872,8 @@ pub struct ServerInput { live_map_enabled: bool, #[serde(default)] economy_group: String, + #[serde(default)] + map_address: String, } fn default_access() -> String { @@ -890,6 +895,19 @@ impl ServerInput { if group.chars().count() > 32 || !group.chars().all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_')) { return Err(AppError::bad_request("economy groups are up to 32 letters, digits, spaces, - or _")); } + let address = self.map_address.trim(); + if !address.is_empty() { + let url = reqwest::Url::parse(address).map_err(|_| AppError::bad_request("map address must be a full web address like https://map.example.com"))?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err(AppError::bad_request("map address must start with http:// or https://")); + } + if url.username() != "" || url.password().is_some() || url.fragment().is_some() { + return Err(AppError::bad_request("map address can't contain credentials or a fragment")); + } + if address.len() > 300 { + return Err(AppError::bad_request("map address is too long")); + } + } if !self.map_url.trim().is_empty() { let url = reqwest::Url::parse(self.map_url.trim()).map_err(|_| AppError::bad_request("map URL must be an absolute URL"))?; if url.scheme() != "https" && !(url.scheme() == "http" && matches!(url.host_str(), Some("localhost" | "127.0.0.1"))) { @@ -918,7 +936,7 @@ pub async fn create(_: AdminUser, State(state): State, Json(input): Js 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, instance_id, map_url, live_map_enabled, economy_group) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", + "INSERT INTO game_servers (name, token_hash, token_hint, access, allowed_groups, require_launcher, created_at, instance_id, map_url, live_map_enabled, economy_group, map_address) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", ) .bind(input.name.trim()) .bind(hash_token(&token)) @@ -931,6 +949,7 @@ pub async fn create(_: AdminUser, State(state): State, Json(input): Js .bind(input.map_url.trim()) .bind(input.live_map_enabled) .bind(input.economy_group.trim()) + .bind(input.map_address.trim()) .fetch_one(&state.db) .await?; let server = get_server(&state, id).await?; @@ -945,7 +964,7 @@ pub async fn update( ) -> AppResult> { input.validate()?; get_server(&state, id).await?; - sqlx::query("UPDATE game_servers SET name = ?, access = ?, allowed_groups = ?, require_launcher = ?, instance_id = ?, map_url = ?, live_map_enabled = ?, economy_group = ? WHERE id = ?") + sqlx::query("UPDATE game_servers SET name = ?, access = ?, allowed_groups = ?, require_launcher = ?, instance_id = ?, map_url = ?, live_map_enabled = ?, economy_group = ?, map_address = ? WHERE id = ?") .bind(input.name.trim()) .bind(&input.access) .bind(serde_json::to_string(&input.allowed_groups).unwrap_or_else(|_| "[]".into())) @@ -954,6 +973,7 @@ pub async fn update( .bind(input.map_url.trim()) .bind(input.live_map_enabled) .bind(input.economy_group.trim()) + .bind(input.map_address.trim()) .bind(id) .execute(&state.db) .await?; @@ -1149,6 +1169,7 @@ pub async fn public_servers(State(state): State) -> AppResult World { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + for n in ["Alex", "Steve", "Mia"] { + t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": n, "password": "password123"}))).await; + } + let (alex, steve) = (t.login("Alex", "password123").await, t.login("Steve", "password123").await); + let (alex_uuid, steve_uuid) = (t.uuid("Alex").await, t.uuid("Steve").await); + let (s, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp", "map_address": "https://map.example.com"}))).await; + assert_eq!(s, StatusCode::OK, "{srv}"); + let sid = srv["server"]["id"].as_i64().unwrap(); + let server = srv["token"].as_str().unwrap().to_string(); + let (_, g) = t.call("POST", "/api/v1/guilds", Some(&alex), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await; + let gid = g["id"].as_str().unwrap().to_string(); + World { t, admin, server, sid, alex, steve, alex_uuid, steve_uuid, gid } +} + +impl World { + async fn online(&self, uuid: &str, name: &str) { + sqlx::query("INSERT OR REPLACE INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, ?, '2026-01-01T00:00:00Z')").bind(self.sid).bind(uuid).bind(name).execute(&self.t.db).await.unwrap(); + } + async fn poll(&self) -> Value { + self.t.call("POST", "/api/server/v1/actions/poll", Some(&self.server), Some(json!({}))).await.1 + } +} + +#[tokio::test] +async fn map_address_is_validated_and_shown_to_launchers() { + let w = world().await; + let (_, list) = w.t.call("GET", "/api/v1/servers/public", None, None).await; + assert_eq!(list["servers"][0]["map_address"], "https://map.example.com"); + for bad in ["not a url", "ftp://map.example.com", "https://user:pw@map.example.com", "https://map.example.com/#x"] { + let (s, _) = w.t.call("PUT", &format!("/api/admin/servers/{}", w.sid), Some(&w.admin), Some(json!({"name": "SMP", "instance_id": "smp", "map_address": bad}))).await; + assert_eq!(s, StatusCode::BAD_REQUEST, "{bad}"); + } + let (s, r) = w.t.call("PUT", &format!("/api/admin/servers/{}", w.sid), Some(&w.admin), Some(json!({"name": "SMP", "instance_id": "smp", "map_address": ""}))).await; + assert_eq!(s, StatusCode::OK, "{r}"); + assert_eq!(r["map_address"], ""); +} + +#[tokio::test] +async fn tpa_and_messages_are_queued_once_for_the_game_server() { + let w = world().await; + let path = format!("/api/v1/servers/{}/map/actions", w.sid); + let tpa = json!({"action": "tpa", "target_uuid": w.steve_uuid}); + + // Nobody is online yet. + assert_eq!(w.t.call("POST", &path, Some(&w.alex), Some(tpa.clone())).await.0, StatusCode::BAD_REQUEST); + w.online(&w.steve_uuid, "Steve").await; + let (s, r) = w.t.call("POST", &path, Some(&w.alex), Some(tpa.clone())).await; + assert_eq!(s, StatusCode::BAD_REQUEST, "the sender has to be in game for a teleport: {r}"); + w.online(&w.alex_uuid, "Alex").await; + assert_eq!(w.t.call("POST", &path, Some(&w.alex), Some(tpa)).await.0, StatusCode::OK); + + let (s, r) = w.t.call("POST", &path, Some(&w.alex), Some(json!({"action": "message", "target_uuid": w.steve_uuid, "text": " hi\n\tthere\u{7} friend "}))).await; + assert_eq!(s, StatusCode::OK, "{r}"); + + let polled = w.poll().await; + let actions = polled["actions"].as_array().unwrap(); + assert_eq!(actions.len(), 2); + assert_eq!((actions[0]["kind"].as_str(), actions[0]["from_name"].as_str()), (Some("tpa"), Some("Alex"))); + assert_eq!(actions[1]["text"], "hi there friend", "control characters and stray spaces are removed"); + assert_eq!(w.poll().await["actions"].as_array().unwrap().len(), 0, "each action is handed out once"); + + // Bad input. + let bad = |body: Value| w.t.call("POST", &path, Some(&w.alex), Some(body)); + assert_eq!(bad(json!({"action": "message", "target_uuid": w.steve_uuid, "text": " "})).await.0, StatusCode::BAD_REQUEST); + assert_eq!(bad(json!({"action": "kill", "target_uuid": w.steve_uuid})).await.0, StatusCode::BAD_REQUEST); + assert_eq!(bad(json!({"action": "tpa", "target_uuid": w.alex_uuid})).await.0, StatusCode::BAD_REQUEST); + assert_eq!(w.t.call("POST", &path, None, Some(json!({"action": "tpa", "target_uuid": w.steve_uuid}))).await.0, StatusCode::UNAUTHORIZED); + + // A burst is slowed down. + let mut last = StatusCode::OK; + for _ in 0..10 { + last = bad(json!({"action": "message", "target_uuid": w.steve_uuid, "text": "spam"})).await.0; + } + assert_eq!(last, StatusCode::TOO_MANY_REQUESTS); +} + +#[tokio::test] +async fn guild_invites_need_consent() { + let w = world().await; + // A member can't invite; the leader can. + let (s, _) = w.t.call("POST", &format!("/api/v1/guilds/{}/invites", w.gid), Some(&w.steve), Some(json!({"uuid": w.alex_uuid}))).await; + assert_eq!(s, StatusCode::FORBIDDEN); + w.online(&w.steve_uuid, "Steve").await; + let (s, r) = w.t.call("POST", &format!("/api/v1/guilds/{}/invites", w.gid), Some(&w.alex), Some(json!({"uuid": w.steve_uuid}))).await; + assert_eq!(s, StatusCode::OK, "{r}"); + let in_guild = |uuid: &str| { + let (db, gid, uuid) = (w.t.db.clone(), w.gid.clone(), uuid.to_string()); + async move { sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)").bind(gid).bind(uuid).fetch_one(&db).await.unwrap() } + }; + assert!(!in_guild(&w.steve_uuid).await, "nobody is added without saying yes"); + + // The invited player is told in game, and sees it in the launcher. + let told = w.poll().await; + assert!(told["actions"][0]["text"].as_str().unwrap().contains("[IRON] Iron")); + assert_eq!(told["actions"][0]["kind"], "notify"); + let (_, mine) = w.t.call("GET", "/api/v1/guilds/invites", Some(&w.steve), None).await; + assert_eq!(mine[0]["guild_tag"], "IRON"); + let id = mine[0]["id"].as_i64().unwrap(); + + // Someone else can't answer it; Steve can. + let mia = w.t.login("Mia", "password123").await; + assert_eq!(w.t.call("POST", &format!("/api/v1/guilds/invites/{id}/accept"), Some(&mia), None).await.0, StatusCode::NOT_FOUND); + let (s, r) = w.t.call("POST", &format!("/api/v1/guilds/invites/{id}/accept"), Some(&w.steve), None).await; + assert_eq!(s, StatusCode::OK, "{r}"); + assert!(in_guild(&w.steve_uuid).await); + assert_eq!(w.t.call("GET", "/api/v1/guilds/invites", Some(&w.steve), None).await.1.as_array().unwrap().len(), 0); + + // Already in a guild here: no second invitation. + let (s, _) = w.t.call("POST", &format!("/api/v1/guilds/{}/invites", w.gid), Some(&w.alex), Some(json!({"uuid": w.steve_uuid}))).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn invites_work_in_game_too() { + let w = world().await; + let server = |path: &'static str, body: Value| { + let (t, token) = (&w.t, w.server.clone()); + async move { t.call("POST", &format!("/api/server/v1/{path}"), Some(&token), Some(body)).await } + }; + let (s, r) = server("guilds/invite/send", json!({"uuid": w.alex_uuid, "target": "steve"})).await; + assert_eq!(s, StatusCode::OK, "{r}"); + assert_eq!(server("guilds/invite/send", json!({"uuid": w.steve_uuid, "target": "alex"})).await.0, StatusCode::FORBIDDEN, "not a leader"); + assert_eq!(server("guilds/invite/send", json!({"uuid": w.alex_uuid, "target": "nobody"})).await.0, StatusCode::NOT_FOUND); + + let (_, list) = server("guilds/invite/list", json!({"uuid": w.steve_uuid})).await; + assert_eq!(list["invites"][0]["guild_tag"], "IRON"); + let (s, r) = server("guilds/invite/respond", json!({"uuid": w.steve_uuid, "tag": "iron", "accept": false})).await; + assert_eq!((s, r["accepted"].clone()), (StatusCode::OK, json!(false))); + assert_eq!(server("guilds/invite/respond", json!({"uuid": w.steve_uuid, "accept": true})).await.0, StatusCode::NOT_FOUND, "a declined invitation is gone"); + + server("guilds/invite/send", json!({"uuid": w.alex_uuid, "target": "Steve"})).await; + let (s, r) = server("guilds/invite/respond", json!({"uuid": w.steve_uuid, "accept": true})).await; + assert_eq!((s, r["accepted"].clone()), (StatusCode::OK, json!(true))); +}