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

+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)
}