Introduced the `delete_room` command to allow users to remove temporary chat rooms, with appropriate access checks for room creators and admins. Updated the `RoomMeta` structure to include the `created_by_chat_user_id` field for better tracking of room ownership. Enhanced error handling in room access validation for improved user feedback during room deletion and initialization processes.
502 lines
17 KiB
Rust
502 lines
17 KiB
Rust
use crate::types::{RoomMeta, ServerConfig, UserProfile};
|
|
use openssl::symm::{Cipher, Crypter, Mode};
|
|
use scrypt::{scrypt, Params as ScryptParams};
|
|
use std::collections::HashSet;
|
|
use std::env;
|
|
use std::sync::Arc;
|
|
use tokio_postgres::{Client as PgClient, NoTls};
|
|
|
|
pub fn parse_allowed_users() -> Option<HashSet<String>> {
|
|
let raw = env::var("CHAT_ALLOWED_USERS").ok()?;
|
|
let users = raw
|
|
.split(',')
|
|
.map(|entry| entry.trim())
|
|
.filter(|entry| !entry.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
.collect::<HashSet<_>>();
|
|
if users.is_empty() {
|
|
None
|
|
} else {
|
|
Some(users)
|
|
}
|
|
}
|
|
|
|
pub async fn connect_db_from_env() -> Result<Option<Arc<PgClient>>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let Some(url) = env::var("CHAT_DB_URL").ok().filter(|v| !v.trim().is_empty()) else {
|
|
return Ok(None);
|
|
};
|
|
let (client, connection) = tokio_postgres::connect(&url, NoTls).await?;
|
|
tokio::spawn(async move {
|
|
if let Err(err) = connection.await {
|
|
eprintln!("[yourchat2] db connection error: {err}");
|
|
}
|
|
});
|
|
println!("[yourchat2] postgres auth enabled");
|
|
Ok(Some(Arc::new(client)))
|
|
}
|
|
|
|
pub async fn load_user_profile(user_name: &str, config: &ServerConfig) -> Result<UserProfile, &'static str> {
|
|
let Some(client) = config.db_client.clone() else {
|
|
return Ok(UserProfile {
|
|
display_name: user_name.to_string(),
|
|
color: None,
|
|
falukant_user_id: None,
|
|
chat_user_id: None,
|
|
gender_id: None,
|
|
age: None,
|
|
rights: HashSet::new(),
|
|
right_type_ids: HashSet::new(),
|
|
});
|
|
};
|
|
|
|
let community_user = client
|
|
.query_opt(
|
|
"SELECT id FROM community.\"user\" WHERE username = $1 LIMIT 1",
|
|
&[&user_name],
|
|
)
|
|
.await
|
|
.map_err(|_| "db_error")?;
|
|
let Some(community_row) = community_user else {
|
|
return Err("user_not_allowed");
|
|
};
|
|
let falukant_user_id: i32 = community_row.get("id");
|
|
|
|
let chat_user = client
|
|
.query_opt(
|
|
"SELECT id, display_name, color FROM chat.\"user\" WHERE falukant_user_id = $1 LIMIT 1",
|
|
&[&falukant_user_id],
|
|
)
|
|
.await
|
|
.map_err(|_| "db_error")?;
|
|
|
|
let (chat_user_id, display_name, color) = if let Some(row) = chat_user {
|
|
(
|
|
row.get::<_, i32>("id"),
|
|
row.get::<_, String>("display_name"),
|
|
row.get::<_, Option<String>>("color"),
|
|
)
|
|
} else {
|
|
let inserted = client
|
|
.query_one(
|
|
"INSERT INTO chat.\"user\" (falukant_user_id, display_name, color, show_gender, show_age, created_at, updated_at) VALUES ($1, $2, $3, true, true, NOW(), NOW()) RETURNING id, display_name, color",
|
|
&[&falukant_user_id, &user_name, &"#000000"],
|
|
)
|
|
.await
|
|
.map_err(|_| "db_error")?;
|
|
(
|
|
inserted.get::<_, i32>("id"),
|
|
inserted.get::<_, String>("display_name"),
|
|
inserted.get::<_, Option<String>>("color"),
|
|
)
|
|
};
|
|
|
|
let mut rights = HashSet::new();
|
|
if let Ok(chat_rights) = load_chat_rights(client.clone(), chat_user_id).await {
|
|
rights.extend(chat_rights);
|
|
}
|
|
let mut right_type_ids = HashSet::new();
|
|
if let Ok((community_rights, community_right_ids)) = load_community_rights(client.clone(), falukant_user_id).await {
|
|
rights.extend(community_rights);
|
|
right_type_ids.extend(community_right_ids);
|
|
}
|
|
|
|
let age = load_user_age(client.clone(), falukant_user_id).await;
|
|
let gender_id = load_user_gender(client.clone(), falukant_user_id).await;
|
|
|
|
Ok(UserProfile {
|
|
display_name,
|
|
color,
|
|
falukant_user_id: Some(falukant_user_id),
|
|
chat_user_id: Some(chat_user_id),
|
|
gender_id,
|
|
age,
|
|
rights,
|
|
right_type_ids,
|
|
})
|
|
}
|
|
|
|
pub fn is_allowed_user(user: &str, config: &ServerConfig) -> bool {
|
|
match &config.allowed_users {
|
|
Some(allowed) => allowed.contains(user),
|
|
None => true,
|
|
}
|
|
}
|
|
|
|
pub fn is_valid_username(input: &str) -> bool {
|
|
let trimmed = input.trim();
|
|
if trimmed.len() < 3 || trimmed.len() > 32 {
|
|
return false;
|
|
}
|
|
trimmed
|
|
.chars()
|
|
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
|
|
}
|
|
|
|
pub async fn save_user_color(config: &ServerConfig, chat_user_id: i32, color: &str) -> Result<(), &'static str> {
|
|
let Some(client) = config.db_client.clone() else {
|
|
return Ok(());
|
|
};
|
|
client
|
|
.execute(
|
|
"UPDATE chat.\"user\" SET color = $1, updated_at = NOW() WHERE id = $2",
|
|
&[&color, &chat_user_id],
|
|
)
|
|
.await
|
|
.map_err(|_| "db_error")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn load_room_configs(config: &ServerConfig) -> Result<Vec<RoomMeta>, &'static str> {
|
|
let Some(client) = config.db_client.clone() else {
|
|
return Ok(vec![RoomMeta {
|
|
name: "lobby".to_string(),
|
|
is_public: true,
|
|
..RoomMeta::default()
|
|
}]);
|
|
};
|
|
|
|
let has_gender_restriction = column_exists(client.clone(), "chat", "room", "gender_restriction_id")
|
|
.await
|
|
.unwrap_or(false);
|
|
let has_required_user_right = column_exists(client.clone(), "chat", "room", "required_user_right_id")
|
|
.await
|
|
.unwrap_or(false);
|
|
let has_friends_of_owner_only = column_exists(client.clone(), "chat", "room", "friends_of_owner_only")
|
|
.await
|
|
.unwrap_or(false);
|
|
let has_room_type = column_exists(client.clone(), "chat", "room", "room_type_id")
|
|
.await
|
|
.unwrap_or(false);
|
|
let has_password_plain = column_exists(client.clone(), "chat", "room", "password")
|
|
.await
|
|
.unwrap_or(false);
|
|
let has_password_hash = column_exists(client.clone(), "chat", "room", "password_hash")
|
|
.await
|
|
.unwrap_or(false);
|
|
let gender_column = if has_gender_restriction {
|
|
"r.gender_restriction_id"
|
|
} else {
|
|
"NULL::int"
|
|
};
|
|
let required_right_column = if has_required_user_right {
|
|
"r.required_user_right_id"
|
|
} else {
|
|
"NULL::int"
|
|
};
|
|
let friends_only_column = if has_friends_of_owner_only {
|
|
"r.friends_of_owner_only"
|
|
} else {
|
|
"false"
|
|
};
|
|
let room_type_column = if has_room_type {
|
|
"r.room_type_id"
|
|
} else {
|
|
"NULL::int"
|
|
};
|
|
let room_password_column = match (has_password_hash, has_password_plain) {
|
|
(true, true) => "COALESCE(NULLIF(r.password_hash, ''), NULLIF(r.\"password\", ''))",
|
|
(true, false) => "NULLIF(r.password_hash, '')",
|
|
(false, true) => "NULLIF(r.\"password\", '')",
|
|
(false, false) => "NULL::text",
|
|
};
|
|
let query = format!(
|
|
"SELECT r.title, {room_password_column} AS room_password, r.is_public, r.owner_id, r.min_age, r.max_age,
|
|
{gender_column} AS gender_restriction_id, {required_right_column} AS required_user_right_id,
|
|
{room_type_column} AS room_type_id, {friends_only_column} AS friends_of_owner_only
|
|
FROM chat.room r
|
|
LEFT JOIN chat.room_type rt ON r.room_type_id = rt.id"
|
|
);
|
|
let rows = client.query(&query, &[]).await.map_err(|_| "db_error")?;
|
|
|
|
let mut rooms = Vec::new();
|
|
for row in rows {
|
|
rooms.push(RoomMeta {
|
|
name: row.get::<_, String>("title"),
|
|
password: row.get::<_, Option<String>>("room_password"),
|
|
gender_restriction_id: row.get::<_, Option<i32>>("gender_restriction_id"),
|
|
required_user_right_id: row.get::<_, Option<i32>>("required_user_right_id"),
|
|
min_age: row.get::<_, Option<i32>>("min_age"),
|
|
max_age: row.get::<_, Option<i32>>("max_age"),
|
|
is_public: row.get::<_, bool>("is_public"),
|
|
owner_id: row.get::<_, Option<i32>>("owner_id"),
|
|
created_by_chat_user_id: None,
|
|
room_type_id: row.get::<_, Option<i32>>("room_type_id"),
|
|
friends_of_owner_only: row.get::<_, bool>("friends_of_owner_only"),
|
|
is_temporary: false,
|
|
created_at_unix: None,
|
|
empty_since_unix: None,
|
|
});
|
|
}
|
|
|
|
if rooms.is_empty() {
|
|
rooms.push(RoomMeta {
|
|
name: "lobby".to_string(),
|
|
is_public: true,
|
|
..RoomMeta::default()
|
|
});
|
|
}
|
|
Ok(rooms)
|
|
}
|
|
|
|
async fn load_user_age(client: Arc<PgClient>, falukant_user_id: i32) -> Option<i32> {
|
|
let mut row = None;
|
|
for description in ["birthdate", "birthday", "geburtsdatum"] {
|
|
row = client
|
|
.query_opt(
|
|
"SELECT up.value
|
|
FROM community.user_param up
|
|
JOIN \"type\".user_param tp ON up.param_type_id = tp.id
|
|
WHERE up.user_id = $1 AND tp.description = $2
|
|
LIMIT 1",
|
|
&[&falukant_user_id, &description],
|
|
)
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
if row.is_some() {
|
|
break;
|
|
}
|
|
}
|
|
let row = row?;
|
|
let encrypted_or_plain = row.get::<_, Option<String>>("value")?;
|
|
let normalized = normalize_birthdate_value(&encrypted_or_plain)?;
|
|
parse_age_from_yyyy_mm_dd(&normalized)
|
|
}
|
|
|
|
fn parse_age_from_yyyy_mm_dd(input: &str) -> Option<i32> {
|
|
let parts = input.split('-').collect::<Vec<_>>();
|
|
if parts.len() != 3 {
|
|
return None;
|
|
}
|
|
let year = parts[0].parse::<i32>().ok()?;
|
|
let month = parts[1].parse::<u32>().ok()?;
|
|
let day = parts[2].parse::<u32>().ok()?;
|
|
|
|
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
|
|
let days = (now / 86_400) as i64;
|
|
let z = days + 719_468;
|
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
|
let doe = z - era * 146_097;
|
|
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
|
let y = yoe + era * 400;
|
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
let mp = (5 * doy + 2) / 153;
|
|
let current_day = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
|
let current_month = (mp + if mp < 10 { 3 } else { -9 }) as u32;
|
|
let current_year = (y + if current_month <= 2 { 1 } else { 0 }) as i32;
|
|
|
|
let mut age = current_year - year;
|
|
if current_month < month || (current_month == month && current_day < day) {
|
|
age -= 1;
|
|
}
|
|
(age >= 0).then_some(age)
|
|
}
|
|
|
|
fn normalize_birthdate_value(input: &str) -> Option<String> {
|
|
if is_yyyy_mm_dd(input) {
|
|
return Some(input.to_string());
|
|
}
|
|
let decrypted = decrypt_aes256_ecb_hex(input)?;
|
|
let trimmed = decrypted.trim();
|
|
if is_yyyy_mm_dd(trimmed) {
|
|
Some(trimmed.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn is_yyyy_mm_dd(input: &str) -> bool {
|
|
let parts = input.split('-').collect::<Vec<_>>();
|
|
if parts.len() != 3 {
|
|
return false;
|
|
}
|
|
parts[0].len() == 4
|
|
&& parts[1].len() == 2
|
|
&& parts[2].len() == 2
|
|
&& parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit()))
|
|
}
|
|
|
|
fn decrypt_aes256_ecb_hex(encrypted_hex: &str) -> Option<String> {
|
|
let secret = env::var("SECRET_KEY").unwrap_or_else(|_| "DEV_FALLBACK_SECRET".to_string());
|
|
let mut key = [0u8; 32];
|
|
let params = ScryptParams::new(14, 8, 1, 32).ok()?;
|
|
scrypt(secret.as_bytes(), b"salt", ¶ms, &mut key).ok()?;
|
|
|
|
let encrypted = hex::decode(encrypted_hex).ok()?;
|
|
if encrypted.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let cipher = Cipher::aes_256_ecb();
|
|
let mut decrypter = Crypter::new(cipher, Mode::Decrypt, &key, None).ok()?;
|
|
decrypter.pad(true);
|
|
|
|
let mut out = vec![0u8; encrypted.len() + cipher.block_size()];
|
|
let mut count = decrypter.update(&encrypted, &mut out).ok()?;
|
|
count += decrypter.finalize(&mut out[count..]).ok()?;
|
|
out.truncate(count);
|
|
|
|
String::from_utf8(out).ok()
|
|
}
|
|
|
|
async fn load_user_gender(client: Arc<PgClient>, user_id: i32) -> Option<i32> {
|
|
for query in [
|
|
"SELECT gender_restriction_id AS gender_id FROM community.\"user\" WHERE id = $1 LIMIT 1",
|
|
"SELECT gender_type_id AS gender_id FROM community.\"user\" WHERE id = $1 LIMIT 1",
|
|
"SELECT gender_id AS gender_id FROM community.\"user\" WHERE id = $1 LIMIT 1",
|
|
"SELECT sex_id AS gender_id FROM community.\"user\" WHERE id = $1 LIMIT 1",
|
|
] {
|
|
match client.query_opt(query, &[&user_id]).await {
|
|
Ok(Some(row)) => {
|
|
let value: Option<i32> = row.try_get("gender_id").ok();
|
|
if value.unwrap_or(0) > 0 {
|
|
return value;
|
|
}
|
|
}
|
|
Ok(None) => {}
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
async fn column_exists(client: Arc<PgClient>, schema: &str, table: &str, column: &str) -> Result<bool, ()> {
|
|
let row = client
|
|
.query_one(
|
|
"SELECT EXISTS(
|
|
SELECT 1
|
|
FROM information_schema.columns
|
|
WHERE table_schema = $1
|
|
AND table_name = $2
|
|
AND column_name = $3
|
|
) AS exists",
|
|
&[&schema, &table, &column],
|
|
)
|
|
.await
|
|
.map_err(|_| ())?;
|
|
Ok(row.get::<_, bool>("exists"))
|
|
}
|
|
|
|
async fn load_chat_rights(client: Arc<PgClient>, chat_user_id: i32) -> Result<HashSet<String>, ()> {
|
|
let rows = client
|
|
.query(
|
|
"SELECT r.tr
|
|
FROM chat.user_rights ur
|
|
JOIN chat.rights r ON ur.chat_right_id = r.id
|
|
WHERE ur.chat_user_id = $1",
|
|
&[&chat_user_id],
|
|
)
|
|
.await
|
|
.map_err(|_| ())?;
|
|
let mut rights = HashSet::new();
|
|
for row in rows {
|
|
let right = row.get::<_, String>("tr");
|
|
rights.insert(right.clone());
|
|
rights.insert(right.to_lowercase());
|
|
}
|
|
Ok(rights)
|
|
}
|
|
|
|
async fn load_community_rights(client: Arc<PgClient>, falukant_user_id: i32) -> Result<(HashSet<String>, HashSet<i32>), ()> {
|
|
let rows = client
|
|
.query(
|
|
"SELECT tur.id AS right_type_id, tur.title
|
|
FROM community.user_right ur
|
|
JOIN \"type\".user_right tur ON ur.right_type_id = tur.id
|
|
WHERE ur.user_id = $1",
|
|
&[&falukant_user_id],
|
|
)
|
|
.await
|
|
.map_err(|_| ())?;
|
|
let mut rights = HashSet::new();
|
|
let mut right_ids = HashSet::new();
|
|
for row in rows {
|
|
let right_title = row.get::<_, String>("title");
|
|
let right_id = row.get::<_, i32>("right_type_id");
|
|
rights.insert(right_title.clone());
|
|
rights.insert(right_title.to_lowercase());
|
|
right_ids.insert(right_id);
|
|
}
|
|
Ok((rights, right_ids))
|
|
}
|
|
|
|
pub async fn user_is_room_owner(config: &ServerConfig, room_owner_id: i32, falukant_user_id: i32) -> bool {
|
|
if room_owner_id <= 0 || falukant_user_id <= 0 {
|
|
return false;
|
|
}
|
|
if room_owner_id == falukant_user_id {
|
|
return true;
|
|
}
|
|
let Some(client) = config.db_client.clone() else {
|
|
return false;
|
|
};
|
|
match client
|
|
.query_opt(
|
|
"SELECT falukant_user_id FROM chat.\"user\" WHERE id = $1 LIMIT 1",
|
|
&[&room_owner_id],
|
|
)
|
|
.await
|
|
{
|
|
Ok(Some(row)) => row.get::<_, Option<i32>>("falukant_user_id") == Some(falukant_user_id),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
pub async fn is_friend_of_room_owner(config: &ServerConfig, room_owner_id: i32, falukant_user_id: i32) -> bool {
|
|
if room_owner_id <= 0 || falukant_user_id <= 0 {
|
|
return false;
|
|
}
|
|
if user_is_room_owner(config, room_owner_id, falukant_user_id).await {
|
|
return true;
|
|
}
|
|
let Some(client) = config.db_client.clone() else {
|
|
return false;
|
|
};
|
|
let owner_falukant_id = match resolve_owner_falukant_id(client.clone(), room_owner_id).await {
|
|
Some(id) => id,
|
|
None => return false,
|
|
};
|
|
if owner_falukant_id == falukant_user_id {
|
|
return true;
|
|
}
|
|
for query in [
|
|
"SELECT 1 FROM community.user_friend uf WHERE ((uf.user_id = $1 AND uf.friend_user_id = $2) OR (uf.user_id = $2 AND uf.friend_user_id = $1)) LIMIT 1",
|
|
"SELECT 1 FROM community.friend f WHERE ((f.user_id = $1 AND f.friend_user_id = $2) OR (f.user_id = $2 AND f.friend_user_id = $1)) LIMIT 1",
|
|
"SELECT 1
|
|
FROM community.friendship f
|
|
WHERE ((f.user1_id = $1 AND f.user2_id = $2) OR (f.user1_id = $2 AND f.user2_id = $1))
|
|
AND COALESCE(f.accepted, false) = true
|
|
AND COALESCE(f.denied, false) = false
|
|
AND COALESCE(f.withdrawn, false) = false
|
|
LIMIT 1",
|
|
"SELECT 1 FROM community.contact c WHERE ((c.user_id = $1 AND c.contact_user_id = $2) OR (c.user_id = $2 AND c.contact_user_id = $1)) LIMIT 1",
|
|
] {
|
|
match client.query_opt(query, &[&falukant_user_id, &owner_falukant_id]).await {
|
|
Ok(Some(_)) => return true,
|
|
Ok(None) => {}
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
async fn resolve_owner_falukant_id(client: Arc<PgClient>, room_owner_id: i32) -> Option<i32> {
|
|
if room_owner_id <= 0 {
|
|
return None;
|
|
}
|
|
if let Ok(Some(_)) = client
|
|
.query_opt("SELECT 1 FROM community.\"user\" WHERE id = $1 LIMIT 1", &[&room_owner_id])
|
|
.await
|
|
{
|
|
return Some(room_owner_id);
|
|
}
|
|
let row = client
|
|
.query_opt(
|
|
"SELECT falukant_user_id FROM chat.\"user\" WHERE id = $1 LIMIT 1",
|
|
&[&room_owner_id],
|
|
)
|
|
.await
|
|
.ok()??;
|
|
row.get::<_, Option<i32>>("falukant_user_id")
|
|
}
|