Initialisiere yourchat2 als eigenständigen Rust-Chatdienst und portiere die Kernfunktionen aus der Altanwendung.

Die Implementierung enthält modulare Command-/State-/DB-Strukturen, DB-basierte Authentifizierung inkl. Rechte- und Raumzugriffsprüfung sowie kompatible Chat- und Dice-Commands.

Made-with: Cursor
This commit is contained in:
Torsten Schulz (local)
2026-03-04 17:04:41 +01:00
commit 0b91b94ae1
10 changed files with 3453 additions and 0 deletions

278
src/db.rs Normal file
View File

@@ -0,0 +1,278 @@
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,
age: None,
rights: 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 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"));
}
let age = load_user_age(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),
age,
rights,
})
}
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 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",
&[],
)
.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>>("password_hash"),
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"),
});
}
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 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 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()
}