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> { 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::>(); if users.is_empty() { None } else { Some(users) } } pub async fn connect_db_from_env() -> Result>, Box> { 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 { 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>("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>("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, &'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>("room_password"), gender_restriction_id: row.get::<_, Option>("gender_restriction_id"), required_user_right_id: row.get::<_, Option>("required_user_right_id"), min_age: row.get::<_, Option>("min_age"), max_age: row.get::<_, Option>("max_age"), is_public: row.get::<_, bool>("is_public"), owner_id: row.get::<_, Option>("owner_id"), created_by_chat_user_id: None, room_type_id: row.get::<_, Option>("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, falukant_user_id: i32) -> Option { 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>("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 { let parts = input.split('-').collect::>(); if parts.len() != 3 { return None; } let year = parts[0].parse::().ok()?; let month = parts[1].parse::().ok()?; let day = parts[2].parse::().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 { 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::>(); 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 { 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, user_id: i32) -> Option { 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 = row.try_get("gender_id").ok(); if value.unwrap_or(0) > 0 { return value; } } Ok(None) => {} Err(_) => {} } } None } async fn column_exists(client: Arc, schema: &str, table: &str, column: &str) -> Result { 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, chat_user_id: i32) -> Result, ()> { 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, falukant_user_id: i32) -> Result<(HashSet, HashSet), ()> { 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>("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, room_owner_id: i32) -> Option { 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>("falukant_user_id") }