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

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