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

+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();