diff --git a/Cargo.lock b/Cargo.lock index 49f393e..55a02a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,6 +53,19 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bcrypt" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523ab528ce3a7ada6597f8ccf5bd8d85ebe26d5edf311cad4d1d3cfb2d357ac6" +dependencies = [ + "base64", + "blowfish", + "getrandom 0.4.2", + "subtle", + "zeroize", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -68,6 +81,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -1602,6 +1625,7 @@ dependencies = [ name = "yourchat2" version = "0.1.0" dependencies = [ + "bcrypt", "futures-util", "hex", "openssl", diff --git a/Cargo.toml b/Cargo.toml index 27eddf9..1834e68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,4 @@ hex = "0.4" scrypt = "0.11" tokio-rustls = "0.26" rustls-pemfile = "2" +bcrypt = "0.19.0" diff --git a/src/commands.rs b/src/commands.rs index 776e484..c251049 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,4 +1,5 @@ use crate::types::{ChatState, ClientId, Command, RoomMeta, ServerConfig}; +use bcrypt::verify as bcrypt_verify; use serde_json::{json, Value}; use std::collections::HashMap; use std::sync::Arc; @@ -169,7 +170,9 @@ async fn handle_init_command( guard.room_meta.get(&resolved_room_name), &profile.display_name, profile.falukant_user_id, + profile.gender_id, profile.age, + &profile.right_type_ids, &room_password, ) { drop(guard); @@ -189,8 +192,10 @@ async fn handle_init_command( client.color = profile.color.clone(); client.falukant_user_id = profile.falukant_user_id; client.chat_user_id = profile.chat_user_id; + client.gender_id = profile.gender_id; client.age = profile.age; client.rights = profile.rights.clone(); + client.right_type_ids = profile.right_type_ids.clone(); client.logged_in = true; client.room = resolved_room_name.clone(); @@ -271,17 +276,25 @@ async fn handle_join_command( send_error(client_id, Arc::clone(&state), "room_not_found_or_join_failed").await; return; }; - let (name_for_check, falukant_user_id, age) = { + let (name_for_check, falukant_user_id, gender_id, age, right_type_ids) = { let Some(client) = guard.clients.get(&client_id) else { return; }; - (client.user_name.clone(), client.falukant_user_id, client.age) + ( + client.user_name.clone(), + client.falukant_user_id, + client.gender_id, + client.age, + client.right_type_ids.clone(), + ) }; if !room_access_allowed( guard.room_meta.get(&resolved_room), &name_for_check, falukant_user_id, + gender_id, age, + &right_type_ids, &password, ) { drop(guard); @@ -770,45 +783,82 @@ fn room_access_allowed( room_meta: Option<&RoomMeta>, _user_name: &str, falukant_user_id: Option, + user_gender_id: Option, age: Option, + user_right_type_ids: &std::collections::HashSet, provided_password: &str, ) -> bool { let Some(room) = room_meta else { return false; }; + if let Some(required_gender) = room.gender_restriction_id { + if required_gender > 0 && user_gender_id != Some(required_gender) { + return false; + } + } + if let Some(room_password) = &room.password { - if !room_password.is_empty() && room_password != provided_password { + if !room_password.is_empty() && !password_matches(room_password, provided_password) { return false; } } if let Some(min_age) = room.min_age { - if min_age >= 18 { + if min_age > 0 { let Some(fid) = falukant_user_id else { return false; }; if fid <= 0 { return false; } - if let Some(user_age) = age { - if user_age < min_age { - return false; - } + let Some(user_age) = age else { + return false; + }; + if user_age < min_age { + return false; } } } if let Some(max_age) = room.max_age { - if let Some(user_age) = age { + if max_age > 0 { + let Some(user_age) = age else { + return false; + }; if user_age > max_age { return false; } } } + if let Some(required_right) = room.required_user_right_id { + if required_right > 0 && !user_right_type_ids.contains(&required_right) { + return false; + } + } true } +fn password_matches(stored_password_or_hash: &str, provided_password: &str) -> bool { + if stored_password_or_hash.is_empty() { + return true; + } + if stored_password_or_hash == provided_password { + return true; + } + if provided_password.is_empty() { + return false; + } + if stored_password_or_hash.starts_with("$2a$") + || stored_password_or_hash.starts_with("$2b$") + || stored_password_or_hash.starts_with("$2x$") + || stored_password_or_hash.starts_with("$2y$") + { + return bcrypt_verify(provided_password, stored_password_or_hash).unwrap_or(false); + } + false +} + fn resolve_room_name(state: &ChatState, requested: &str) -> Option { if state.room_meta.contains_key(requested) { return Some(requested.to_string()); diff --git a/src/db.rs b/src/db.rs index 8e3968f..780a962 100644 --- a/src/db.rs +++ b/src/db.rs @@ -42,8 +42,10 @@ pub async fn load_user_profile(user_name: &str, config: &ServerConfig) -> Result color: None, falukant_user_id: None, chat_user_id: None, + gender_id: None, age: None, rights: HashSet::new(), + right_type_ids: HashSet::new(), }); }; @@ -88,27 +90,28 @@ pub async fn load_user_profile(user_name: &str, config: &ServerConfig) -> Result ) }; - let rights_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(|_| "db_error")?; let mut rights = HashSet::new(); - for row in rights_rows { - rights.insert(row.get::<_, String>("tr")); + 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, }) } @@ -152,21 +155,36 @@ pub async fn load_room_configs(config: &ServerConfig) -> Result, & }]); }; - let rows = client - .query( - "SELECT r.title, r.password_hash, r.is_public, r.owner_id, r.min_age, r.max_age - FROM chat.room r - LEFT JOIN chat.room_type rt ON r.room_type_id = rt.id", - &[], - ) + let has_gender_restriction = column_exists(client.clone(), "chat", "room", "gender_restriction_id") .await - .map_err(|_| "db_error")?; + .unwrap_or(false); + let has_required_user_right = column_exists(client.clone(), "chat", "room", "required_user_right_id") + .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 query = format!( + "SELECT r.title, r.password_hash, 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 + 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>("password_hash"), + 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"), @@ -185,17 +203,25 @@ pub async fn load_room_configs(config: &ServerConfig) -> Result, & } async fn load_user_age(client: Arc, falukant_user_id: i32) -> Option { - let 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 = 'birthdate' - LIMIT 1", - &[&falukant_user_id], - ) - .await - .ok()??; + 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) @@ -276,3 +302,84 @@ fn decrypt_aes256_ecb_hex(encrypted_hex: &str) -> Option { 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)) +} diff --git a/src/main.rs b/src/main.rs index 8ebfcad..8caf6dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -232,15 +232,19 @@ async fn print_rooms_for_cli( if config.db_client.is_some() { "database" } else { "fallback" } ); println!( - "{:<30} {:<8} {:<8} {:<8} {:<10}", - "name", "public", "min_age", "max_age", "password" + "{:<24} {:<8} {:<8} {:<8} {:<8} {:<10} {:<8}", + "name", "public", "gender", "min_age", "max_age", "password", "right_id" ); - println!("{}", "-".repeat(72)); + println!("{}", "-".repeat(92)); for room in rooms { println!( - "{:<30} {:<8} {:<8} {:<8} {:<10}", + "{:<24} {:<8} {:<8} {:<8} {:<8} {:<10} {:<8}", room.name, if room.is_public { "yes" } else { "no" }, + room.gender_restriction_id + .filter(|v| *v > 0) + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".to_string()), room.min_age .map(|v| v.to_string()) .unwrap_or_else(|| "-".to_string()), @@ -252,6 +256,10 @@ async fn print_rooms_for_cli( } else { "set" }, + room.required_user_right_id + .filter(|v| *v > 0) + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".to_string()), ); } Ok(()) @@ -283,8 +291,10 @@ where token: None, falukant_user_id: None, chat_user_id: None, + gender_id: None, age: None, rights: HashSet::new(), + right_type_ids: HashSet::new(), logged_in: false, tx: tx.clone(), }, @@ -353,8 +363,10 @@ where token: None, falukant_user_id: None, chat_user_id: None, + gender_id: None, age: None, rights: HashSet::new(), + right_type_ids: HashSet::new(), logged_in: false, tx: tx.clone(), }, diff --git a/src/types.rs b/src/types.rs index 66b8147..c584c3a 100644 --- a/src/types.rs +++ b/src/types.rs @@ -15,8 +15,10 @@ pub(crate) struct ClientConn { pub(crate) token: Option, pub(crate) falukant_user_id: Option, pub(crate) chat_user_id: Option, + pub(crate) gender_id: Option, pub(crate) age: Option, pub(crate) rights: HashSet, + pub(crate) right_type_ids: HashSet, pub(crate) logged_in: bool, pub(crate) tx: mpsc::UnboundedSender, } @@ -43,8 +45,10 @@ pub(crate) struct UserProfile { pub(crate) color: Option, pub(crate) falukant_user_id: Option, pub(crate) chat_user_id: Option, + pub(crate) gender_id: Option, pub(crate) age: Option, pub(crate) rights: HashSet, + pub(crate) right_type_ids: HashSet, } #[derive(Debug, Deserialize)] @@ -83,6 +87,8 @@ pub(crate) struct DiceGame { pub(crate) struct RoomMeta { pub(crate) name: String, pub(crate) password: Option, + pub(crate) gender_restriction_id: Option, + pub(crate) required_user_right_id: Option, pub(crate) min_age: Option, pub(crate) max_age: Option, pub(crate) is_public: bool,