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:
278
src/db.rs
Normal file
278
src/db.rs
Normal 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", ¶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()
|
||||
}
|
||||
Reference in New Issue
Block a user