Files
yourchat2/src/db.rs
Torsten Schulz (local) 553602d5b4 Add delete room command and enhance room management in yourchat2
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.
2026-03-04 23:08:22 +01:00

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", &params, &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")
}