Panel: server map address, map actions queue, guild invitations

This commit is contained in:
Claude committed 2026-09-30 20:13:44 +00:00
1 parent eba2ccfdab
commit 878827625a
7 files changed
+520 -6

No files matched your search

+27
View File
@@ -680,6 +680,33 @@ const MIGRATIONS: &[&str] = &[
r#" r#"
ALTER TABLE game_servers ADD COLUMN economy_group TEXT NOT NULL DEFAULT ''; 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<SqlitePool> { pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
+2
View File
@@ -72,6 +72,8 @@ pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult<PurgeRepo
report.add("friends", run(c, "DELETE FROM friendships WHERE user_uuid = ? OR friend_uuid = ?", &[uuid, uuid]).await?); report.add("friends", run(c, "DELETE FROM friendships WHERE user_uuid = ? OR friend_uuid = ?", &[uuid, uuid]).await?);
report.add("messages", run(c, "DELETE FROM direct_messages WHERE sender_uuid = ? OR recipient_uuid = ?", &[uuid, uuid]).await?); report.add("messages", run(c, "DELETE FROM direct_messages WHERE sender_uuid = ? OR recipient_uuid = ?", &[uuid, uuid]).await?);
report.add("invites", run(c, "DELETE FROM game_invites WHERE sender_uuid = ? OR recipient_uuid = ?", &[uuid, uuid]).await?); report.add("invites", run(c, "DELETE FROM game_invites WHERE sender_uuid = ? OR recipient_uuid = ?", &[uuid, uuid]).await?);
report.add("invites", run(c, "DELETE FROM guild_invites WHERE inviter_uuid = ? OR target_uuid = ?", &[uuid, uuid]).await?);
report.add("actions", run(c, "DELETE FROM server_actions WHERE from_uuid = ? OR to_uuid = ?", &[uuid, uuid]).await?);
report.add("profile", run(c, "DELETE FROM user_profiles WHERE uuid = ?", &[uuid]).await?); report.add("profile", run(c, "DELETE FROM user_profiles WHERE uuid = ?", &[uuid]).await?);
// Likes they gave come off other people's post counters. // Likes they gave come off other people's post counters.
run( run(
+4 -4
View File
@@ -932,8 +932,8 @@ pub async fn server_claim_index(
if payload.revision.as_deref() == Some(revision.as_str()) { if payload.revision.as_deref() == Some(revision.as_str()) {
return Ok(Json(json!({ "revision": revision, "unchanged": true }))); return Ok(Json(json!({ "revision": revision, "unchanged": true })));
} }
let rows: Vec<(String, i32, i32, String, String, String)> = sqlx::query_as( let rows: Vec<(String, i32, i32, String, String, String, Option<String>)> = sqlx::query_as(
"SELECT gc.dimension, gc.chunk_x, gc.chunk_z, gc.guild_id, g.name, g.tag "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 = ?", FROM guild_claims gc JOIN guilds g ON g.id = gc.guild_id WHERE gc.server_id = ?",
) )
.bind(server.id) .bind(server.id)
@@ -950,9 +950,9 @@ pub async fn server_claim_index(
let mut guilds: Vec<Value> = Vec::new(); let mut guilds: Vec<Value> = Vec::new();
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new(); let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut claims: Vec<Value> = Vec::with_capacity(rows.len()); let mut claims: Vec<Value> = 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(|| { 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 guilds.len() - 1
}); });
claims.push(json!([dimension, x, z, i])); claims.push(json!([dimension, x, z, i]));
+300
View File
@@ -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<bool> {
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::<String>().split_whitespace().collect::<Vec<_>>().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<i64>, State(state): State<AppState>, Json(a): Json<MapAction>) -> AppResult<Json<Value>> {
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<AppState>) -> AppResult<Json<Value>> {
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<Value> = 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<i64> = 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<Value> {
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<String> = 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<String> = 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<String>, State(state): State<AppState>, Json(p): Json<InvitePayload>) -> AppResult<Json<Value>> {
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<Vec<Value>> {
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<AppState>) -> AppResult<Json<Value>> {
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<i64>, tag: Option<&str>, accept: bool) -> AppResult<Value> {
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<i64>, State(state): State<AppState>) -> AppResult<Json<Value>> {
Ok(Json(respond(&state, &auth.uuid, &auth.username, Some(id), None, true).await?))
}
pub async fn decline_invite(auth: AuthUser, Path(id): Path<i64>, State(state): State<AppState>) -> AppResult<Json<Value>> {
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<AppState>, Json(p): Json<ServerInvite>) -> AppResult<Json<Value>> {
let inviter: Option<String> = 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<String> = 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<String> = 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<AppState>, Json(p): Json<ServerInvite>) -> AppResult<Json<Value>> {
Ok(Json(json!({ "invites": pending_for(&state, &p.uuid).await? })))
}
pub async fn server_invite_respond(GameServer(_): GameServer, State(state): State<AppState>, Json(p): Json<ServerInvite>) -> AppResult<Json<Value>> {
let name: Option<String> = 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?))
}
+10
View File
@@ -11,6 +11,7 @@ pub mod discord;
pub mod integrations; pub mod integrations;
pub mod landing; pub mod landing;
pub mod livemap; pub mod livemap;
pub mod map;
pub mod leveling; pub mod leveling;
pub mod meta; pub mod meta;
pub mod progression; pub mod progression;
@@ -53,6 +54,11 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/servers/public", get(servers::public_servers)) .route("/servers/public", get(servers::public_servers))
.route("/servers/{id}/leaderboard", get(servers::public_server_leaderboard)) .route("/servers/{id}/leaderboard", get(servers::public_server_leaderboard))
.route("/servers/{id}/livemap", get(livemap::viewer_info)) .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("/leaderboard", get(servers::global_leaderboard))
.route("/landing", get(landing::public_landing)) .route("/landing", get(landing::public_landing))
.route("/launcher/authlib-injector.json", get(account::authlib_index)) .route("/launcher/authlib-injector.json", get(account::authlib_index))
@@ -195,6 +201,10 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/guilds/create", post(guilds::server_create_guild)) .route("/guilds/create", post(guilds::server_create_guild))
.route("/guilds/leave", post(guilds::server_guild_leave)) .route("/guilds/leave", post(guilds::server_guild_leave))
.route("/integrations/report", post(integrations::server_report)) .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/info", post(dev_api::player_info))
.route("/player/xp", post(dev_api::add_xp)) .route("/player/xp", post(dev_api::add_xp))
.route("/player/quest-objective", post(dev_api::quest_objective)) .route("/player/quest-objective", post(dev_api::quest_objective))
+23 -2
View File
@@ -63,6 +63,9 @@ pub struct ServerRow {
pub instance_id: String, pub instance_id: String,
pub map_url: String, pub map_url: String,
pub live_map_enabled: bool, 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. /// Servers with the same non-empty name share player balances and guild banks.
#[sqlx(default)] #[sqlx(default)]
pub economy_group: String, pub economy_group: String,
@@ -869,6 +872,8 @@ pub struct ServerInput {
live_map_enabled: bool, live_map_enabled: bool,
#[serde(default)] #[serde(default)]
economy_group: String, economy_group: String,
#[serde(default)]
map_address: String,
} }
fn default_access() -> 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, ' ' | '-' | '_')) { 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 _")); 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() { 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"))?; 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"))) { 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<AppState>, Json(input): Js
input.validate()?; input.validate()?;
let token = new_token(); let token = new_token();
let id: i64 = sqlx::query_scalar( 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(input.name.trim())
.bind(hash_token(&token)) .bind(hash_token(&token))
@@ -931,6 +949,7 @@ pub async fn create(_: AdminUser, State(state): State<AppState>, Json(input): Js
.bind(input.map_url.trim()) .bind(input.map_url.trim())
.bind(input.live_map_enabled) .bind(input.live_map_enabled)
.bind(input.economy_group.trim()) .bind(input.economy_group.trim())
.bind(input.map_address.trim())
.fetch_one(&state.db) .fetch_one(&state.db)
.await?; .await?;
let server = get_server(&state, id).await?; let server = get_server(&state, id).await?;
@@ -945,7 +964,7 @@ pub async fn update(
) -> AppResult<Json<ServerView>> { ) -> AppResult<Json<ServerView>> {
input.validate()?; input.validate()?;
get_server(&state, id).await?; 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.name.trim())
.bind(&input.access) .bind(&input.access)
.bind(serde_json::to_string(&input.allowed_groups).unwrap_or_else(|_| "[]".into())) .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.map_url.trim())
.bind(input.live_map_enabled) .bind(input.live_map_enabled)
.bind(input.economy_group.trim()) .bind(input.economy_group.trim())
.bind(input.map_address.trim())
.bind(id) .bind(id)
.execute(&state.db) .execute(&state.db)
.await?; .await?;
@@ -1149,6 +1169,7 @@ pub async fn public_servers(State(state): State<AppState>) -> AppResult<Json<Val
"name": s.name, "name": s.name,
"instance_id": s.instance_id, "instance_id": s.instance_id,
"map_url": s.map_url, "map_url": s.map_url,
"map_address": s.map_address,
"live_map": s.live_map_enabled, "live_map": s.live_map_enabled,
"online": is_online, "online": is_online,
"players_online": if is_online { s.online_count } else { 0 }, "players_online": if is_online { s.online_count } else { 0 },
+154
View File
@@ -0,0 +1,154 @@
//! The map: the address shown in launchers, actions on clicked players, and guild invitations.
mod common;
use common::*;
struct World {
t: TestApp,
admin: String,
server: String,
sid: i64,
alex: String,
steve: String,
alex_uuid: String,
steve_uuid: String,
gid: String,
}
async fn world() -> 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)));
}