diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index b6c5ba8..77bf135 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -593,6 +593,9 @@ pub struct MemberProfile { pub last_seen: Option, pub online: bool, pub is_friend: bool, + /// `none`, `accepted`, `pending_outgoing` or `pending_incoming`. + #[serde(default)] + pub friendship_status: String, } // --------------------------------------------------------------------------- diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index e873ec1..1147cd0 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -994,10 +994,12 @@ pub async fn remove_friend(state: State<'_, AppState>, friend_uuid: Option, query: String) -> Res> { - 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) } diff --git a/launcher/src/pages/Social.svelte b/launcher/src/pages/Social.svelte index 3f2547c..c8abd74 100644 --- a/launcher/src/pages/Social.svelte +++ b/launcher/src/pages/Social.svelte @@ -59,7 +59,7 @@ try { membersList = await invoke('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 @@
{#each membersList as member (member.uuid)} {@const isMe = member.uuid === activeAccount()?.uuid} -
+ +
{ if (!(e.target as HTMLElement).closest('button')) app.viewProfileUuid = member.uuid; }} + onkeydown={(e) => e.key === 'Enter' && e.target === e.currentTarget && (app.viewProfileUuid = member.uuid)}>
(app.viewProfileUuid = member.uuid)} onkeydown={(e) => e.key === 'Enter' && (app.viewProfileUuid = member.uuid)}> @@ -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); diff --git a/panel/server/src/lib.rs b/panel/server/src/lib.rs index 50b3de8..a61f405 100644 --- a/panel/server/src/lib.rs +++ b/panel/server/src/lib.rs @@ -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; diff --git a/panel/server/src/livemap.rs b/panel/server/src/livemap.rs index 62afdaf..29fe098 100644 --- a/panel/server/src/livemap.rs +++ b/panel/server/src/livemap.rs @@ -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 { self.players.lock().unwrap().get(&id).filter(|s| s.at.elapsed() < PLAYERS_FRESH).map(|s| s.players.clone()).unwrap_or_default() } diff --git a/panel/server/src/purge.rs b/panel/server/src/purge.rs new file mode 100644 index 0000000..b989338 --- /dev/null +++ b/panel/server/src/purge.rs @@ -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 { + 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 { + 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 = 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) +} diff --git a/panel/server/src/routes/admin.rs b/panel/server/src/routes/admin.rs index 664b7f1..197caa4 100644 --- a/panel/server/src/routes/admin.rs +++ b/panel/server/src/routes/admin.rs @@ -227,8 +227,15 @@ pub async fn delete_user(AdminUser(me): AdminUser, State(state): State 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)] diff --git a/panel/server/src/routes/social.rs b/panel/server/src/routes/social.rs index 77c37ea..62d5891 100644 --- a/panel/server/src/routes/social.rs +++ b/panel/server/src/routes/social.rs @@ -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, Json(payload): Json, ) -> AppResult> { + 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, State(state): State, ) -> AppResult>> { - 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, Option, Option, i64, Option, Option)> = 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, Option, Option, i64, Option, bool, Option, Option)> = + 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(); diff --git a/panel/server/tests/social.rs b/panel/server/tests/social.rs new file mode 100644 index 0000000..795641d --- /dev/null +++ b/panel/server/tests/social.rs @@ -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 { + let tables: Vec = 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 = 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::::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); +} diff --git a/panel/web/src/pages/Users.svelte b/panel/web/src/pages/Users.svelte index 44160f6..5bd245b 100644 --- a/panel/web/src/pages/Users.svelte +++ b/panel/web/src/pages/Users.svelte @@ -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 } }>(`/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 @@ -

They won't be able to sign in any more. This can't be undone.

+

This removes the account and all of its data: 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.

{#snippet footer()}