Panel: server map address, map actions queue, guild invitations
This commit is contained in:
7 files changed
+520
-6
No files matched your search
@@ -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> {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|||||||
@@ -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?))
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user