Fix Friends & Social member search; purge all account data on deletion

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-09-30 17:46:10 +00:00
1 parent 1a321336b4
commit e344e7144e
10 files changed
+430 -43

No files matched your search

+3
View File
@@ -593,6 +593,9 @@ pub struct MemberProfile {
pub last_seen: Option<String>,
pub online: bool,
pub is_friend: bool,
/// `none`, `accepted`, `pending_outgoing` or `pending_incoming`.
#[serde(default)]
pub friendship_status: String,
}
// ---------------------------------------------------------------------------
+5 -3
View File
@@ -994,10 +994,12 @@ pub async fn remove_friend(state: State<'_, AppState>, friend_uuid: Option<Strin
#[tauri::command]
pub async fn search_members(state: State<'_, AppState>, query: String) -> Res<Vec<MemberProfile>> {
let req = account_api(&state, reqwest::Method::GET, &format!("/social/members?q={query}")).await?;
let resp = req.send().await.map_err(err)?;
let req = account_api(&state, reqwest::Method::GET, "/social/members").await?;
// `.query` percent-encodes, so names with spaces, `&` or `#` search correctly.
let resp = req.query(&[("q", query.trim())]).send().await.map_err(err)?;
if !resp.status().is_success() {
return Err("unable to search members".into());
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to search members").to_string());
}
resp.json().await.map_err(err)
}
+7 -2
View File
@@ -59,7 +59,7 @@
try {
membersList = await invoke<MemberProfile[]>('search_members', { query: memberSearchQuery.trim() });
} catch (e: any) {
toast(e?.message ?? 'Failed to search members', 'error');
toast(typeof e === 'string' ? e : e?.message ?? 'Failed to search members', 'error');
} finally {
loadingMembers = false;
}
@@ -417,7 +417,10 @@
<div class="members-grid">
{#each membersList as member (member.uuid)}
{@const isMe = member.uuid === activeAccount()?.uuid}
<div class="member-card glass" class:is-me={isMe}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="member-card glass clickable" class:is-me={isMe} role="button" tabindex="0" title="Open {member.username}'s profile"
onclick={(e) => { if (!(e.target as HTMLElement).closest('button')) app.viewProfileUuid = member.uuid; }}
onkeydown={(e) => e.key === 'Enter' && e.target === e.currentTarget && (app.viewProfileUuid = member.uuid)}>
<div class="member-card-top">
<div class="avatar-wrap" role="button" tabindex="0" onclick={() => (app.viewProfileUuid = member.uuid)} onkeydown={(e) => e.key === 'Enter' && (app.viewProfileUuid = member.uuid)}>
<Avatar uuid={member.uuid} name={member.username} size={2.8} />
@@ -1068,6 +1071,8 @@
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.member-card.clickable { cursor: pointer; }
.member-card.clickable:hover { border-color: var(--line-strong); }
.member-card {
padding: 1.1rem;
border-radius: var(--radius);
+1
View File
@@ -11,6 +11,7 @@ pub mod livemap;
pub mod net;
pub mod packs;
pub mod progression;
pub mod purge;
pub mod routes;
pub mod seed;
pub mod state;
+7
View File
@@ -408,6 +408,13 @@ impl LiveMap {
}
}
/// Drop a player from every server's live roster (account deleted).
pub fn forget_player(&self, uuid: &str) {
for snap in self.players.lock().unwrap().values_mut() {
snap.players.retain(|p| !p.uuid.eq_ignore_ascii_case(uuid));
}
}
pub fn live_players(&self, id: i64) -> Vec<LivePlayer> {
self.players.lock().unwrap().get(&id).filter(|s| s.at.elapsed() < PLAYERS_FRESH).map(|s| s.players.clone()).unwrap_or_default()
}
+155
View File
@@ -0,0 +1,155 @@
//! Removing an account removes everything the panel knows about its player.
//!
//! Rows that belong to the player are deleted. Rows that belong to *other*
//! people but mention the player (the other side of an economy transfer, who
//! performed a guild-bank deposit) are kept with the player anonymised.
use crate::auth::UserRow;
use crate::error::AppResult;
use crate::state::AppState;
use serde::Serialize;
use sqlx::SqliteConnection;
/// Stand-in UUID for deleted players in other people's records.
pub const DELETED_UUID: &str = "00000000-0000-0000-0000-000000000000";
pub const DELETED_NAME: &str = "Deleted player";
#[derive(Debug, Default, Serialize)]
pub struct PurgeReport {
/// Rows deleted, by area.
pub removed: std::collections::BTreeMap<&'static str, u64>,
/// Guilds dissolved because the player was their only member.
pub guilds_dissolved: u64,
/// Guilds whose leadership was handed to someone else.
pub guilds_transferred: u64,
pub skin_file_removed: bool,
}
impl PurgeReport {
fn add(&mut self, area: &'static str, n: u64) {
if n > 0 {
*self.removed.entry(area).or_default() += n;
}
}
pub fn total(&self) -> u64 {
self.removed.values().sum()
}
}
async fn run(conn: &mut SqliteConnection, sql: &str, binds: &[&str]) -> AppResult<u64> {
let mut q = sqlx::query(sql);
for b in binds {
q = q.bind(*b);
}
Ok(q.execute(&mut *conn).await?.rows_affected())
}
/// Delete an account and all data tied to it. Runs in one transaction.
pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult<PurgeReport> {
let uuid = user.uuid.as_str();
let name = user.username.as_str();
let mut report = PurgeReport::default();
let mut tx = state.db.begin().await?;
let c = &mut *tx;
// ---- progression ----
report.add("levels", run(c, "DELETE FROM user_levels WHERE uuid = ?", &[uuid]).await?);
report.add("levels", run(c, "DELETE FROM server_levels WHERE uuid = ?", &[uuid]).await?);
report.add("rewards", run(c, "DELETE FROM granted_rewards WHERE uuid = ?", &[uuid]).await?);
report.add("quests", run(c, "DELETE FROM user_quests WHERE user_uuid = ?", &[uuid]).await?);
report.add("quests", run(c, "DELETE FROM quest_assignments WHERE user_uuid = ?", &[uuid]).await?);
report.add("achievements", run(c, "DELETE FROM user_achievements WHERE user_uuid = ?", &[uuid]).await?);
// ---- gameplay records ----
report.add("stats", run(c, "DELETE FROM player_stats WHERE uuid = ?", &[uuid]).await?);
report.add("stats", run(c, "DELETE FROM server_online WHERE uuid = ?", &[uuid]).await?);
report.add("activity", run(c, "DELETE FROM server_events WHERE uuid = ?", &[uuid]).await?);
report.add("activity", run(c, "DELETE FROM events WHERE uuid = ? OR (uuid IS NULL AND username = ? COLLATE NOCASE)", &[uuid, name]).await?);
// ---- social ----
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("profile", run(c, "DELETE FROM user_profiles WHERE uuid = ?", &[uuid]).await?);
// Likes they gave come off other people's post counters.
run(
c,
"UPDATE user_posts SET likes_count = MAX(0, likes_count - 1) WHERE id IN (SELECT post_id FROM user_post_likes WHERE user_uuid = ?)",
&[uuid],
)
.await?;
report.add("posts", run(c, "DELETE FROM user_post_likes WHERE user_uuid = ?", &[uuid]).await?);
report.add("posts", run(c, "DELETE FROM user_posts WHERE user_uuid = ?", &[uuid]).await?);
// ---- guilds ----
let led: Vec<String> = sqlx::query_scalar("SELECT id FROM guilds WHERE leader_uuid = ?").bind(uuid).fetch_all(&mut *c).await?;
for guild in led {
// Next in line: an officer, then whoever has been a member longest.
let heir: Option<(String, String)> = sqlx::query_as(
"SELECT uuid, name FROM guild_members WHERE guild_id = ? AND uuid <> ?
ORDER BY (role = 'officer') DESC, joined_at ASC LIMIT 1",
)
.bind(&guild)
.bind(uuid)
.fetch_optional(&mut *c)
.await?;
match heir {
Some((heir_uuid, _)) => {
run(c, "UPDATE guilds SET leader_uuid = ? WHERE id = ?", &[&heir_uuid, &guild]).await?;
run(c, "UPDATE guild_members SET role = 'leader' WHERE guild_id = ? AND uuid = ?", &[&guild, &heir_uuid]).await?;
report.guilds_transferred += 1;
}
None => {
// Claims, wallet, posts and roles go with the guild.
report.add("guild_data", run(c, "DELETE FROM guilds WHERE id = ?", &[&guild]).await?);
report.guilds_dissolved += 1;
}
}
}
report.add("guild_membership", run(c, "DELETE FROM guild_members WHERE uuid = ?", &[uuid]).await?);
report.add("guild_posts", run(c, "DELETE FROM guild_posts WHERE author_uuid = ?", &[uuid]).await?);
// Territory they claimed stays with the guild.
run(c, "UPDATE guild_claims SET claimed_by_uuid = ? WHERE claimed_by_uuid = ?", &[DELETED_UUID, uuid]).await?;
run(c, "UPDATE guild_wallet_transactions SET actor_uuid = ? WHERE actor_uuid = ?", &[DELETED_UUID, uuid]).await?;
// ---- economy ----
report.add("economy", run(c, "DELETE FROM server_economy WHERE uuid = ?", &[uuid]).await?);
report.add("economy", run(c, "DELETE FROM server_market WHERE seller_uuid = ?", &[uuid]).await?);
run(c, "UPDATE economy_transactions SET from_uuid = ?, from_name = ? WHERE from_uuid = ?", &[DELETED_UUID, DELETED_NAME, uuid]).await?;
run(c, "UPDATE economy_transactions SET to_uuid = ?, to_name = ? WHERE to_uuid = ?", &[DELETED_UUID, DELETED_NAME, uuid]).await?;
// ---- identity ----
report.add("name_reservations", run(c, "DELETE FROM reserved_usernames WHERE uuid = ?", &[uuid]).await?);
// Explicit, in case a foreign key is ever relaxed.
let id = user.id.to_string();
for (area, sql) in [
("sessions", "DELETE FROM ygg_tokens WHERE user_id = ?"),
("sessions", "DELETE FROM ygg_sessions WHERE user_id = ?"),
("sessions", "DELETE FROM player_keys WHERE user_id = ?"),
("sessions", "DELETE FROM launcher_sessions WHERE user_id = ?"),
("connections", "DELETE FROM account_connections WHERE user_id = ?"),
("connections", "DELETE FROM oauth_attempts WHERE user_id = ?"),
("connections", "DELETE FROM password_resets WHERE user_id = ?"),
("groups", "DELETE FROM user_groups WHERE user_id = ?"),
] {
report.add(area, run(c, sql, &[&id]).await?);
}
report.add("account", run(c, "DELETE FROM users WHERE id = ?", &[&id]).await?);
// A skin nobody else uses is deleted from disk.
let skin = user.skin_hash.clone();
let skin_shared: bool = match &skin {
Some(h) => sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE skin_hash = ?)").bind(h).fetch_one(&mut *c).await?,
None => true,
};
tx.commit().await?;
if let (Some(hash), false) = (skin, skin_shared) {
if let Some(path) = crate::textures::path(&state.cfg.textures_dir(), &hash) {
report.skin_file_removed = tokio::fs::remove_file(path).await.is_ok();
}
}
// Live map: stop showing them.
state.livemap.forget_player(uuid);
Ok(report)
}
+9 -2
View File
@@ -227,8 +227,15 @@ pub async fn delete_user(AdminUser(me): AdminUser, State(state): State<AppState>
if me.id == id {
return Err(AppError::bad_request("you can't delete your own account"));
}
sqlx::query("DELETE FROM users WHERE id = ?").bind(id).execute(&state.db).await?;
Ok(Json(json!({ "ok": true })))
let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id = ?")
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| AppError::not_found("player not found"))?;
// Removes the account and everything tied to its UUID, not just the login.
let report = crate::purge::purge_user(&state, &user).await?;
crate::routes::activity::record(&state, &me, "panel", "account_deleted", Some(&format!("{} ({} records removed)", user.username, report.total()))).await?;
Ok(Json(json!({ "ok": true, "report": report })))
}
#[derive(Serialize, Deserialize, sqlx::FromRow)]
+65 -33
View File
@@ -304,6 +304,20 @@ pub async fn send_direct_message(
return Err(AppError::bad_request("Message cannot be empty"));
}
if target_uuid == auth.uuid {
return Err(AppError::bad_request("You can't message yourself"));
}
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ? AND status = 'active')")
.bind(&target_uuid)
.fetch_one(&state.db)
.await?;
if !exists {
return Err(AppError::not_found("Player not found"));
}
if content.chars().count() > 1000 {
return Err(AppError::bad_request("Messages can be up to 1000 characters"));
}
let now = chrono::Utc::now().to_rfc3339();
let id: i64 = sqlx::query_scalar(
@@ -400,6 +414,24 @@ pub async fn send_game_invite(
State(state): State<AppState>,
Json(payload): Json<SendInvitePayload>,
) -> AppResult<Json<Value>> {
let friends: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM friendships WHERE status = 'accepted'
AND ((user_uuid = ?1 AND friend_uuid = ?2) OR (user_uuid = ?2 AND friend_uuid = ?1)))",
)
.bind(&auth.uuid)
.bind(&payload.recipient_uuid)
.fetch_one(&state.db)
.await?;
if !friends {
return Err(AppError::forbidden("You can only invite friends"));
}
let instance: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM instances WHERE id = ?)")
.bind(&payload.instance_id)
.fetch_one(&state.db)
.await?;
if !instance {
return Err(AppError::not_found("Instance not found"));
}
let invite_id = format!("inv_{}", uuid::Uuid::new_v4().simple());
let now = chrono::Utc::now();
let expires = now + chrono::Duration::minutes(30);
@@ -700,54 +732,54 @@ pub async fn search_members(
Query(query): Query<SearchMembersQuery>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<scopenet_shared::MemberProfile>>> {
let q = query.q.unwrap_or_default().trim().to_lowercase();
// `%` and `_` are LIKE wildcards; players shouldn't be able to inject them.
let q = query.q.unwrap_or_default().trim().to_lowercase().replace(['%', '_', '\\'], "");
let pattern = format!("%{q}%");
let rows: Vec<(String, String, String, String, Option<String>, Option<i64>, Option<String>, i64, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT u.uuid, u.username, u.role, u.status, u.skin_url,
ul.global_level, ul.title, u.playtime_secs, u.last_seen_ingame,
f.status as friendship_status
FROM users u
LEFT JOIN user_levels ul ON ul.uuid = u.uuid
LEFT JOIN friendships f ON (f.user_uuid = ? AND f.friend_uuid = u.uuid)
WHERE (? = '' OR LOWER(u.username) LIKE ?)
ORDER BY (CASE WHEN LOWER(u.username) = ? THEN 1 ELSE 2 END), u.playtime_secs DESC
LIMIT 60",
)
.bind(&auth.uuid)
.bind(&q)
.bind(&pattern)
.bind(&q)
.fetch_all(&state.db)
.await?;
let now_ts = chrono::Utc::now().timestamp();
let rows: Vec<(String, String, String, String, Option<String>, Option<i64>, Option<String>, i64, Option<String>, bool, Option<String>, Option<String>)> =
sqlx::query_as(
"SELECT u.uuid, u.username, u.role, u.status, u.skin_hash,
ul.global_level, ul.title,
COALESCE((SELECT SUM(ps.playtime_secs) FROM player_stats ps WHERE ps.uuid = u.uuid), 0),
(SELECT MAX(ps.last_seen) FROM player_stats ps WHERE ps.uuid = u.uuid),
EXISTS(SELECT 1 FROM server_online so WHERE so.uuid = u.uuid),
f.status, f.action_uuid
FROM users u
LEFT JOIN user_levels ul ON ul.uuid = u.uuid
LEFT JOIN friendships f ON (f.user_uuid = ?1 AND f.friend_uuid = u.uuid) OR (f.friend_uuid = ?1 AND f.user_uuid = u.uuid)
WHERE u.status = 'active' AND u.uuid <> ?1 AND (?2 = '' OR LOWER(u.username) LIKE ?3)
ORDER BY (CASE WHEN LOWER(u.username) = ?2 THEN 0 ELSE 1 END), 10 DESC, u.username COLLATE NOCASE
LIMIT 60",
)
.bind(&auth.uuid)
.bind(&q)
.bind(&pattern)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.map(|(uuid, username, role, status, skin_url, glvl, title, playtime, last_seen, friend_status)| {
let online = if let Some(ref ls) = last_seen {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ls) {
(now_ts - dt.timestamp()).abs() < 180
} else {
false
}
} else {
false
};
let is_friend = friend_status.as_deref() == Some("accepted");
.map(|(uuid, username, role, status, skin_hash, glvl, title, playtime, last_seen, online, friend_status, action)| {
let friendship_status = match (friend_status.as_deref(), action.as_deref()) {
(Some("accepted"), _) => "accepted",
(Some(_), Some(a)) if a == auth.uuid => "pending_outgoing",
(Some(_), _) => "pending_incoming",
_ => "none",
}
.to_string();
scopenet_shared::MemberProfile {
uuid,
username,
role,
status,
skin_url,
skin_url: skin_hash.map(|h| format!("/textures/{h}")),
global_level: glvl.unwrap_or(1),
title,
playtime_secs: playtime,
last_seen,
online,
is_friend,
is_friend: friendship_status == "accepted",
friendship_status,
}
})
.collect();
+174
View File
@@ -0,0 +1,174 @@
//! Friends & Social, and removing every trace of a deleted account.
mod common;
use common::*;
async fn player(t: &TestApp, admin: &str, name: &str) -> (String, String, i64) {
let (s, v) = t.call("POST", "/api/admin/users", Some(admin), Some(json!({"username": name, "password": "password123"}))).await;
assert_eq!(s, StatusCode::OK, "{v}");
(t.login(name, "password123").await, t.uuid(name).await, v["id"].as_i64().unwrap())
}
#[tokio::test]
async fn search_befriend_message_invite() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
let (alex, alex_uuid, _) = player(&t, &admin, "Alexandra").await;
let (steve, steve_uuid, _) = player(&t, &admin, "Steve").await;
player(&t, &admin, "Stevie_B").await;
// Search: name match, wildcards are literal, never yourself.
let (s, v) = t.call("GET", "/api/v1/social/members?q=ste", Some(&alex), None).await;
assert_eq!(s, StatusCode::OK, "{v}");
let names: Vec<&str> = v.as_array().unwrap().iter().map(|m| m["username"].as_str().unwrap()).collect();
assert!(names.contains(&"Steve") && names.contains(&"Stevie_B") && !names.contains(&"Alexandra"), "{names:?}");
assert_eq!(v[0]["friendship_status"], "none");
let (_, all) = t.call("GET", "/api/v1/members/search", Some(&alex), None).await;
assert_eq!(all.as_array().unwrap().len(), 3, "admin + Steve + Stevie_B");
let (_, wild) = t.call("GET", "/api/v1/social/members?q=%25", Some(&alex), None).await;
assert_eq!(wild.as_array().unwrap().len(), 3, "a bare % is ignored, not a wildcard");
let (s, _) = t.call("GET", "/api/v1/social/members?q=x", None, None).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
// Friend request flow shows up in search results for both sides.
let (s, _) = t.call("POST", "/api/v1/friends/request", Some(&alex), Some(json!({"username": "steve"}))).await;
assert_eq!(s, StatusCode::OK);
let (_, v) = t.call("GET", "/api/v1/social/members?q=steve", Some(&alex), None).await;
assert_eq!(v[0]["friendship_status"], "pending_outgoing");
let (_, v) = t.call("GET", "/api/v1/social/members?q=alexandra", Some(&steve), None).await;
assert_eq!(v[0]["friendship_status"], "pending_incoming");
let (s, _) = t.call("POST", "/api/v1/friends/respond", Some(&steve), Some(json!({"target_uuid": alex_uuid, "accept": true}))).await;
assert_eq!(s, StatusCode::OK);
let (_, v) = t.call("GET", "/api/v1/social/members?q=steve", Some(&alex), None).await;
assert_eq!(v[0]["friendship_status"], "accepted");
assert_eq!(v[0]["is_friend"], true);
let (_, friends) = t.call("GET", "/api/v1/friends", Some(&alex), None).await;
assert_eq!(friends[0]["username"], "Steve");
// Messages: only to real players.
let (s, m) = t.call("POST", &format!("/api/v1/messages/{steve_uuid}"), Some(&alex), Some(json!({"content": "hello"}))).await;
assert_eq!(s, StatusCode::OK, "{m}");
let (s, _) = t.call("POST", "/api/v1/messages/nobody", Some(&alex), Some(json!({"content": "hello"}))).await;
assert_eq!(s, StatusCode::NOT_FOUND);
let (_, thread) = t.call("GET", &format!("/api/v1/messages/{alex_uuid}"), Some(&steve), None).await;
assert_eq!(thread[0]["content"], "hello");
// Invites: friends only.
let (_, stevie) = t.call("GET", "/api/v1/social/members?q=stevie", Some(&alex), None).await;
let stevie_uuid = stevie[0]["uuid"].as_str().unwrap().to_string();
let (s, _) = t.call("POST", "/api/v1/invites", Some(&alex), Some(json!({"recipient_uuid": stevie_uuid, "instance_id": "x"}))).await;
assert_eq!(s, StatusCode::FORBIDDEN);
// Profile carries the relationship so the launcher can offer the right actions.
let (s, p) = t.call("GET", &format!("/api/v1/profiles/{steve_uuid}"), Some(&alex), None).await;
assert_eq!(s, StatusCode::OK, "{p}");
let (s, p) = t.call("GET", &format!("/api/v1/profiles/{stevie_uuid}"), Some(&alex), None).await;
assert_eq!(s, StatusCode::OK, "{p}");
}
/// Every table and column is scanned, so a table added later is covered too.
async fn mentions(t: &TestApp, needle: &str) -> Vec<String> {
let tables: Vec<String> = sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").fetch_all(&t.db).await.unwrap();
let mut hits = Vec::new();
for table in tables {
let cols: Vec<String> = sqlx::query_scalar(&format!("SELECT name FROM pragma_table_info('{table}')")).fetch_all(&t.db).await.unwrap();
for col in cols {
let n: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM \"{table}\" WHERE CAST(\"{col}\" AS TEXT) = ?")).bind(needle).fetch_one(&t.db).await.unwrap();
if n > 0 {
hits.push(format!("{table}.{col}"));
}
}
}
hits
}
#[tokio::test]
async fn deleting_an_account_removes_all_its_data() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
let (alex, alex_uuid, _) = player(&t, &admin, "Alex").await;
let (steve, steve_uuid, steve_id) = player(&t, &admin, "Steve").await;
let (_, mia_uuid, _) = player(&t, &admin, "Mia").await;
let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await;
let sid = srv["server"]["id"].as_i64().unwrap();
let server_token = srv["token"].as_str().unwrap().to_string();
// Gameplay: stats, XP, quests, an achievement, events.
let (s, v) = t
.call(
"POST",
"/api/server/v1/sync",
Some(&server_token),
Some(json!({
"online": [{"uuid": &steve_uuid, "name": "Steve"}],
"stats": [{"uuid": &steve_uuid, "name": "Steve", "blocks_broken": 200, "mob_kills": 20, "playtime_secs": 4000, "joins": 1}],
"events": [{"uuid": &steve_uuid, "name": "Steve", "kind": "join"}, {"uuid": &steve_uuid, "kind": "action", "detail": "block_broken:STONE@minecraft:overworld +10"}]
})),
)
.await;
assert_eq!(s, StatusCode::OK, "{v}");
t.call("GET", "/api/v1/quests/my", Some(&steve), None).await;
// Social: friends, DMs both ways, profile, posts, likes.
t.call("POST", "/api/v1/friends/request", Some(&steve), Some(json!({"username": "Alex"}))).await;
t.call("POST", "/api/v1/friends/respond", Some(&alex), Some(json!({"target_uuid": &steve_uuid, "accept": true}))).await;
t.call("POST", &format!("/api/v1/messages/{alex_uuid}"), Some(&steve), Some(json!({"content": "hi"}))).await;
t.call("POST", &format!("/api/v1/messages/{steve_uuid}"), Some(&alex), Some(json!({"content": "yo"}))).await;
t.call("PUT", "/api/v1/profiles/me", Some(&steve), Some(json!({"bio": "I like rocks"}))).await;
let (_, post) = t.call("POST", "/api/v1/profiles/me/posts", Some(&steve), Some(json!({"content": "hello world"}))).await;
let (_, alex_post) = t.call("POST", "/api/v1/profiles/me/posts", Some(&alex), Some(json!({"content": "alex post"}))).await;
t.call("POST", &format!("/api/v1/posts/{}/like", alex_post["id"]), Some(&steve), None).await;
t.call("POST", &format!("/api/v1/posts/{}/like", post["id"]), Some(&alex), None).await;
// Economy: balance, a transfer to Alex, a market listing.
sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 50, 'now'), (?, ?, 'Alex', 50, 'now')")
.bind(sid).bind(&steve_uuid).bind(sid).bind(&alex_uuid).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO economy_transactions (server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at) VALUES (?, ?, 'Steve', ?, 'Alex', 5, 'pay', 'now')")
.bind(sid).bind(&steve_uuid).bind(&alex_uuid).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO server_market (server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at) VALUES (?, ?, 'Steve', 'DIAMOND', 'Diamond', 1, 10, 'now')")
.bind(sid).bind(&steve_uuid).execute(&t.db).await.unwrap();
// Guilds: Steve leads one with Alex in it, and is sole member of another.
let (s, g) = t.call("POST", "/api/v1/guilds", Some(&steve), Some(json!({"instance_id": "smp", "name": "Rockheads", "tag": "ROCK"}))).await;
assert_eq!(s, StatusCode::OK, "{g}");
let gid = g["id"].as_str().unwrap().to_string();
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Alex', 'member', '2026-01-01')").bind(&gid).bind(&alex_uuid).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO guild_wallet_transactions (server_id, guild_id, actor_uuid, kind, amount, created_at) VALUES (?, ?, ?, 'deposit', 5, 'now')").bind(sid).bind(&gid).bind(&steve_uuid).execute(&t.db).await.unwrap();
let (s, g2) = t.call("POST", "/api/v1/guilds", Some(&admin), Some(json!({"instance_id": "smp", "name": "Solo", "tag": "SOLO"}))).await;
assert_eq!(s, StatusCode::OK, "{g2}");
assert!(!mentions(&t, &steve_uuid).await.is_empty());
// Delete the account.
let (s, v) = t.call("DELETE", &format!("/api/admin/users/{steve_id}"), Some(&admin), None).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert!(v["report"]["guilds_transferred"] == 1 && v["report"]["removed"]["stats"].as_u64().unwrap() > 0, "{v}");
// Nothing refers to them any more (their name appears only as plain text in free-form rows we don't keep).
assert_eq!(mentions(&t, &steve_uuid).await, Vec::<String>::new());
for table in ["users", "user_levels", "player_stats", "friendships", "direct_messages", "user_profiles", "user_posts", "server_market", "server_economy", "reserved_usernames"] {
let n: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table} WHERE CAST(uuid AS TEXT) = ?")).bind(&steve_uuid).fetch_one(&t.db).await.unwrap_or(0);
assert_eq!(n, 0, "{table}");
}
let name_hits: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = 'Steve' COLLATE NOCASE").fetch_one(&t.db).await.unwrap();
assert_eq!(name_hits, 0);
// Other people keep what is theirs: Alex is now the guild's leader; the
// transfer shows the anonymised counterparty; Alex's like count dropped.
let leader: String = sqlx::query_scalar("SELECT leader_uuid FROM guilds WHERE id = ?").bind(&gid).fetch_one(&t.db).await.unwrap();
assert_eq!(leader, alex_uuid);
let from: String = sqlx::query_scalar("SELECT from_name FROM economy_transactions").fetch_one(&t.db).await.unwrap();
assert_eq!(from, "Deleted player");
let likes: i64 = sqlx::query_scalar("SELECT likes_count FROM user_posts WHERE id = ?").bind(alex_post["id"].as_i64().unwrap()).fetch_one(&t.db).await.unwrap();
assert_eq!(likes, 0);
let (_, friends) = t.call("GET", "/api/v1/friends", Some(&alex), None).await;
assert!(friends.as_array().unwrap().is_empty());
let (_, list) = t.call("GET", "/api/v1/members/search", Some(&alex), None).await;
assert!(list.as_array().unwrap().iter().all(|m| m["username"] != "Steve"));
// The old name can be taken again, and the deleted token no longer works.
let (s, _) = t.call("GET", "/api/v1/auth/me", Some(&steve), None).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
player(&t, &admin, "Steve").await;
assert_ne!(mia_uuid, steve_uuid);
}
+4 -3
View File
@@ -79,8 +79,9 @@
async function removeUser() {
if (!confirm) return;
try {
await del(`/api/admin/users/${confirm.id}`);
toast('Player deleted');
const r = await del<{ report?: { removed: Record<string, number> } }>(`/api/admin/users/${confirm.id}`);
const n = Object.values(r?.report?.removed ?? {}).reduce((a, b) => a + b, 0);
toast(`Player deleted — ${n.toLocaleString()} records removed`);
confirmOpen = false;
refresh();
} catch (e) {
@@ -253,7 +254,7 @@
</Modal>
<Modal bind:open={confirmOpen} title="Delete {confirm?.username}?">
<p class="muted">They won't be able to sign in any more. This can't be undone.</p>
<p class="muted">This removes the account <strong>and all of its data</strong>: levels, XP, quests, achievements, stats, friends, messages, profile and posts, economy balance and market listings, and guild membership. Guilds they lead pass to the next member (or are dissolved if they're alone). Their name becomes available again. This can't be undone.</p>
{#snippet footer()}
<button class="ghost" onclick={() => (confirmOpen = false)}>Cancel</button>
<button class="danger" onclick={removeUser}><Trash2 size={16} /> Delete</button>