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#"
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> {
+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("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 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?);
// Likes they gave come off other people's post counters.
run(
+4 -4
View File
@@ -932,8 +932,8 @@ pub async fn server_claim_index(
if payload.revision.as_deref() == Some(revision.as_str()) {
return Ok(Json(json!({ "revision": revision, "unchanged": true })));
}
let rows: Vec<(String, i32, i32, String, String, String)> = 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<String>)> = 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<Value> = Vec::new();
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
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(|| {
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]));
+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 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<AppState> {
.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<AppState> {
.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))
+23 -2
View File
@@ -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<AppState>, 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<AppState>, 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<Json<ServerView>> {
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<AppState>) -> AppResult<Json<Val
"name": s.name,
"instance_id": s.instance_id,
"map_url": s.map_url,
"map_address": s.map_address,
"live_map": s.live_map_enabled,
"online": is_online,
"players_online": if is_online { s.online_count } else { 0 },