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

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

1438
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "yourchat2"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "io-util", "sync", "signal", "fs"] }
uuid = { version = "1", features = ["v4"] }
tokio-tungstenite = "0.28"
futures-util = "0.3"
tokio-postgres = "0.7"
openssl = "0.10"
hex = "0.4"
scrypt = "0.11"

117
README.md Normal file
View File

@@ -0,0 +1,117 @@
# yourchat2
Rust-basierter Chat-Service/Daemon zur Entlastung des Backends von `YourPart3`.
Die Kommunikation erfolgt ueber WebSocket (Frontend) und optional TCP/Unix-Socket (Bridge/Backend).
## Features
- Asynchroner WebSocket-Server fuer `MultiChatDialog` (Standard `0.0.0.0:1235`)
- Zusaetzlicher TCP-Server fuer `chatTcpBridge.js` (Standard `127.0.0.1:1236`)
- Optional zusaetzlicher Unix Domain Socket
- In-Memory-Room-Management
- Token-basierte Session-Absicherung pro Verbindung
- Username-Pruefung (3-32 Zeichen, `[a-zA-Z0-9_]`)
- Verhindert Mehrfach-Login mit identischem Namen (`loggedin`)
- Optionale Allowlist fuer User via `CHAT_ALLOWED_USERS`
- Optionale DB-basierte User-Pruefung via Postgres (`CHAT_DB_URL`)
- Kompatible Antworttypen fuer die bestehende `chatTcpBridge.js`
## Start
```bash
cd ~/Programs/yourchat2
cargo run --release
```
Der Server lauscht danach standardmaessig auf:
- `ws://0.0.0.0:1235` (Frontend `MultiChatDialog`)
- `tcp://127.0.0.1:1236` (Backend-Bridge)
## Konfiguration (Umgebungsvariablen)
- `CHAT_WS_ADDR` (default: `0.0.0.0:1235`)
- `CHAT_TCP_ADDR` (default: `127.0.0.1:1236`)
- `CHAT_UNIX_SOCKET` (optional, z. B. `/run/yourchat2/yourchat2.sock`)
- `CHAT_ALLOWED_USERS` (optional, CSV-Liste erlaubter Usernamen, z. B. `alice,bob,carol`)
- `CHAT_DB_URL` (optional, PostgreSQL-Connection-String fuer Community-/Chat-User-Checks)
- `SECRET_KEY` (benoetigt fuer Entschluesselung verschluesselter Birthdate-Werte aus der DB)
Beispiel:
```bash
CHAT_WS_ADDR=0.0.0.0:1235 CHAT_TCP_ADDR=127.0.0.1:1236 CHAT_UNIX_SOCKET=/tmp/yourchat2.sock cargo run --release
```
## Protokoll (Kurzfassung)
Alle Requests/Responses sind JSON-Objekte.
- WebSocket: ein JSON pro Frame (Text)
- TCP/Unix-Socket: newline-delimited JSON
### Eingehende Commands
- `{"type":"init","name":"alice","room":"lobby"}`
- `{"type":"join","room":"sports","token":"..."}`
- `{"type":"message","message":"Hi","token":"..."}`
- `{"type":"scream","message":"Hallo alle","token":"..."}`
- `{"type":"do","message":"winkt","token":"..."}`
- `{"type":"dice","message":"1d6","token":"..."}`
- `{"type":"roll","value":4,"token":"..."}`
- `{"type":"color","value":"#33aaee","token":"..."}`
- `{"type":"rooms","token":"..."}`
- `{"type":"userlist","token":"..."}`
- `{"type":"start_dice_game","rounds":3,"token":"..."}`
- `{"type":"end_dice_game","token":"..."}`
- `{"type":"reload_rooms","token":"..."}`
- `{"type":"ping"}`
Zusatz: Bei `{"type":"message", ...}` werden Slash-Commands erkannt:
- `/join <room> [password]`
- `/color <hex>`
- `/dice [expr]` oder `/roll [expr]`
- `/start_dice_game <runden 1..10>`
- `/end_dice_game`
- `/reload_rooms`
- `/do <aktion>`
- `/scream <text>`
- `/rooms` und `/userlist`
Wenn `CHAT_DB_URL` gesetzt ist, prueft `init` den User gegen `community."user"`.
Falls fuer den Community-User noch kein Eintrag in `chat."user"` existiert, wird er automatisch angelegt.
Farbe und Rechte werden aus der DB geladen.
Fuer Alterspruefungen in Raeumen wird `community.user_param.value` (birthdate) per AES-256-ECB
mit einem via scrypt aus `SECRET_KEY` abgeleiteten Schluessel entschluesselt (kompatibel zur Alt-App).
### Wichtige Antworten
- Token: `{"type":1,"message":"<token>"}`
- Roomliste: `{"type":3,"message":[{"name":"lobby","users":2}]}`
- Room betreten: `{"type":5,"message":"room_entered","to":"lobby"}`
- Farbaenderung: `{"type":5,"message":"color_changed","userName":"alice","color":"#33aaee"}`
- Normale Message: `{"type":"message","userName":"alice","message":"Hi","color":"#33aaee"}`
- Scream: `{"type":6,"userName":"alice","message":"Hallo alle","color":"#33aaee"}`
## Integration mit `YourPart3`
In `YourPart3/backend/config/chatBridge.json` sollte stehen:
```json
{
"host": "127.0.0.1",
"port": 1236
}
```
Dann verbindet sich die bestehende Bridge (`chatTcpBridge.js`) direkt mit `yourchat2`.
## Systemd (optional)
Es liegt eine Beispiel-Datei `yourchat2.service` im Projekt.
Nach Anpassung des User/Paths:
```bash
sudo cp yourchat2.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now yourchat2
```

1021
src/commands.rs Normal file

File diff suppressed because it is too large Load Diff

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()
}

321
src/main.rs Normal file
View File

@@ -0,0 +1,321 @@
use futures_util::{SinkExt, StreamExt};
use std::collections::HashSet;
use std::env;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, UnixListener};
use tokio::sync::{mpsc, watch, RwLock};
use tokio_tungstenite::{accept_async, tungstenite::Message};
mod commands;
mod db;
mod state;
mod types;
use types::{ChatState, ClientConn, ServerConfig};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let ws_addr = env::var("CHAT_WS_ADDR").unwrap_or_else(|_| "0.0.0.0:1235".to_string());
let tcp_addr = env::var("CHAT_TCP_ADDR").unwrap_or_else(|_| "127.0.0.1:1236".to_string());
let unix_socket = env::var("CHAT_UNIX_SOCKET").ok().filter(|s| !s.trim().is_empty());
let state = Arc::new(RwLock::new(ChatState::default()));
let db_client = db::connect_db_from_env().await?;
let config = Arc::new(ServerConfig {
allowed_users: db::parse_allowed_users(),
db_client,
});
let rooms = db::load_room_configs(&config).await.unwrap_or_else(|_| {
vec![types::RoomMeta {
name: "lobby".to_string(),
is_public: true,
..types::RoomMeta::default()
}]
});
{
let mut guard = state.write().await;
for room in rooms {
guard.rooms.entry(room.name.clone()).or_default();
guard.room_meta.insert(room.name.clone(), room);
}
}
let next_client_id = Arc::new(AtomicU64::new(1));
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let ws_listener = TcpListener::bind(&ws_addr).await?;
println!("[yourchat2] listening on ws://{}", ws_addr);
let ws_state = Arc::clone(&state);
let ws_config = Arc::clone(&config);
let ws_next = Arc::clone(&next_client_id);
let mut ws_shutdown_rx = shutdown_rx.clone();
let ws_task = tokio::spawn(async move {
loop {
tokio::select! {
changed = ws_shutdown_rx.changed() => {
if changed.is_ok() && *ws_shutdown_rx.borrow() {
break;
}
}
accepted = ws_listener.accept() => {
match accepted {
Ok((socket, addr)) => {
println!("[yourchat2] ws client connected: {}", addr);
let state = Arc::clone(&ws_state);
let config = Arc::clone(&ws_config);
let next = Arc::clone(&ws_next);
let shutdown = ws_shutdown_rx.clone();
tokio::spawn(async move {
if let Err(err) = handle_ws_client(socket, state, config, next, shutdown).await {
eprintln!("[yourchat2] ws client error: {err}");
}
});
}
Err(err) => eprintln!("[yourchat2] ws accept error: {err}"),
}
}
}
}
});
let tcp_listener = TcpListener::bind(&tcp_addr).await?;
println!("[yourchat2] listening on tcp://{}", tcp_addr);
let tcp_state = Arc::clone(&state);
let tcp_config = Arc::clone(&config);
let tcp_next = Arc::clone(&next_client_id);
let mut tcp_shutdown_rx = shutdown_rx.clone();
let tcp_task = tokio::spawn(async move {
loop {
tokio::select! {
changed = tcp_shutdown_rx.changed() => {
if changed.is_ok() && *tcp_shutdown_rx.borrow() {
break;
}
}
accepted = tcp_listener.accept() => {
match accepted {
Ok((socket, addr)) => {
println!("[yourchat2] tcp client connected: {}", addr);
let state = Arc::clone(&tcp_state);
let config = Arc::clone(&tcp_config);
let next = Arc::clone(&tcp_next);
let shutdown = tcp_shutdown_rx.clone();
tokio::spawn(async move {
if let Err(err) = handle_client(socket, state, config, next, shutdown).await {
eprintln!("[yourchat2] client error: {err}");
}
});
}
Err(err) => eprintln!("[yourchat2] accept error: {err}"),
}
}
}
}
});
let unix_task = if let Some(socket_path) = unix_socket.clone() {
let path = Path::new(&socket_path);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
if path.exists() {
tokio::fs::remove_file(path).await?;
}
let listener = UnixListener::bind(path)?;
println!("[yourchat2] listening on unix://{}", socket_path);
let unix_state = Arc::clone(&state);
let unix_config = Arc::clone(&config);
let unix_next = Arc::clone(&next_client_id);
let mut unix_shutdown_rx = shutdown_rx.clone();
Some(tokio::spawn(async move {
loop {
tokio::select! {
changed = unix_shutdown_rx.changed() => {
if changed.is_ok() && *unix_shutdown_rx.borrow() {
break;
}
}
accepted = listener.accept() => {
match accepted {
Ok((socket, _addr)) => {
let state = Arc::clone(&unix_state);
let config = Arc::clone(&unix_config);
let next = Arc::clone(&unix_next);
let shutdown = unix_shutdown_rx.clone();
tokio::spawn(async move {
if let Err(err) = handle_client(socket, state, config, next, shutdown).await {
eprintln!("[yourchat2] unix client error: {err}");
}
});
}
Err(err) => eprintln!("[yourchat2] unix accept error: {err}"),
}
}
}
}
}))
} else {
None
};
tokio::signal::ctrl_c().await?;
println!("[yourchat2] shutdown requested");
let _ = shutdown_tx.send(true);
let _ = ws_task.await;
let _ = tcp_task.await;
if let Some(task) = unix_task {
let _ = task.await;
if let Some(path) = unix_socket {
let _ = tokio::fs::remove_file(path).await;
}
}
println!("[yourchat2] stopped");
Ok(())
}
async fn handle_client<S>(
stream: S,
state: Arc<RwLock<ChatState>>,
config: Arc<ServerConfig>,
next_client_id: Arc<AtomicU64>,
mut shutdown_rx: watch::Receiver<bool>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let client_id = next_client_id.fetch_add(1, Ordering::Relaxed);
let default_name = format!("Guest-{client_id}");
let (read_half, mut write_half) = tokio::io::split(stream);
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
{
let mut guard = state.write().await;
guard.clients.insert(
client_id,
ClientConn {
user_name: default_name.clone(),
room: String::new(),
color: None,
token: None,
falukant_user_id: None,
chat_user_id: None,
age: None,
rights: HashSet::new(),
logged_in: false,
tx: tx.clone(),
},
);
}
let writer_task = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if write_half.write_all(msg.as_bytes()).await.is_err() {
break;
}
if write_half.write_all(b"\n").await.is_err() {
break;
}
}
});
let mut lines = BufReader::new(read_half).lines();
loop {
tokio::select! {
changed = shutdown_rx.changed() => {
if changed.is_ok() && *shutdown_rx.borrow() {
break;
}
}
line = lines.next_line() => {
match line? {
Some(raw) => {
commands::process_text_command(client_id, &raw, Arc::clone(&state), Arc::clone(&config)).await;
}
None => break,
}
}
}
}
state::disconnect_client(client_id, state).await;
writer_task.abort();
Ok(())
}
async fn handle_ws_client(
socket: tokio::net::TcpStream,
state: Arc<RwLock<ChatState>>,
config: Arc<ServerConfig>,
next_client_id: Arc<AtomicU64>,
mut shutdown_rx: watch::Receiver<bool>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let ws_stream = accept_async(socket).await?;
let (mut ws_write, mut ws_read) = ws_stream.split();
let client_id = next_client_id.fetch_add(1, Ordering::Relaxed);
let default_name = format!("Guest-{client_id}");
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
{
let mut guard = state.write().await;
guard.clients.insert(
client_id,
ClientConn {
user_name: default_name,
room: String::new(),
color: None,
token: None,
falukant_user_id: None,
chat_user_id: None,
age: None,
rights: HashSet::new(),
logged_in: false,
tx: tx.clone(),
},
);
}
let writer_task = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if ws_write.send(Message::Text(msg.into())).await.is_err() {
break;
}
}
});
loop {
tokio::select! {
changed = shutdown_rx.changed() => {
if changed.is_ok() && *shutdown_rx.borrow() {
break;
}
}
incoming = ws_read.next() => {
match incoming {
Some(Ok(Message::Text(text))) => {
commands::process_text_command(client_id, &text, Arc::clone(&state), Arc::clone(&config)).await;
}
Some(Ok(Message::Binary(bin))) => {
if let Ok(text) = std::str::from_utf8(&bin) {
commands::process_text_command(client_id, text, Arc::clone(&state), Arc::clone(&config)).await;
}
}
Some(Ok(Message::Ping(_))) => {}
Some(Ok(Message::Close(_))) => break,
Some(Ok(_)) => {}
Some(Err(_)) | None => break,
}
}
}
}
state::disconnect_client(client_id, state).await;
writer_task.abort();
Ok(())
}

154
src/state.rs Normal file
View File

@@ -0,0 +1,154 @@
use crate::types::{ChatState, ClientId, Command, RoomInfo};
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::RwLock;
pub async fn authorize(client_id: ClientId, command: &Command, state: Arc<RwLock<ChatState>>) -> bool {
let auth_error = {
let guard = state.read().await;
let Some(client) = guard.clients.get(&client_id) else {
return false;
};
if !client.logged_in {
Some("not_initialized")
} else {
match (&client.token, &command.token) {
(Some(expected), Some(got)) if expected == got => None,
(Some(_), Some(_)) => Some("invalid_token"),
(Some(_), None) => Some("missing_token"),
_ => None,
}
}
};
if let Some(message) = auth_error {
send_to_client(client_id, state, json!({"type":"error","message": message})).await;
false
} else {
true
}
}
pub async fn send_room_list(client_id: ClientId, state: Arc<RwLock<ChatState>>) {
let list = {
let guard = state.read().await;
let mut rooms: Vec<RoomInfo> = guard
.rooms
.iter()
.map(|(name, members)| RoomInfo {
name: name.clone(),
users: members.len(),
})
.collect();
rooms.sort_by(|a, b| a.name.cmp(&b.name));
rooms
};
send_to_client(client_id, state, json!({"type":3, "message": list})).await;
}
pub async fn send_user_list(client_id: ClientId, state: Arc<RwLock<ChatState>>) {
let users = {
let guard = state.read().await;
let Some(client) = guard.clients.get(&client_id) else {
return;
};
if client.room.is_empty() {
Vec::<Value>::new()
} else if let Some(members) = guard.rooms.get(&client.room) {
members
.iter()
.filter_map(|id| guard.clients.get(id))
.map(|u| json!({"name": u.user_name, "color": u.color}))
.collect::<Vec<_>>()
} else {
Vec::<Value>::new()
}
};
send_to_client(client_id, state, json!({"type":2, "message": users})).await;
}
pub async fn send_to_client(client_id: ClientId, state: Arc<RwLock<ChatState>>, payload: Value) {
let msg = payload.to_string();
let tx = {
let guard = state.read().await;
guard.clients.get(&client_id).map(|c| c.tx.clone())
};
if let Some(tx) = tx {
let _ = tx.send(msg);
}
}
pub async fn broadcast_all(state: Arc<RwLock<ChatState>>, payload: Value) {
let msg = payload.to_string();
let targets = {
let guard = state.read().await;
guard.clients.values().map(|c| c.tx.clone()).collect::<Vec<_>>()
};
for tx in targets {
let _ = tx.send(msg.clone());
}
}
pub async fn broadcast_room(
room: &str,
state: Arc<RwLock<ChatState>>,
payload: Value,
exclude: Option<ClientId>,
) {
let msg = payload.to_string();
let targets = {
let guard = state.read().await;
let Some(members) = guard.rooms.get(room) else {
return;
};
members
.iter()
.filter_map(|id| {
if exclude.is_some() && exclude == Some(*id) {
return None;
}
guard.clients.get(id).map(|c| c.tx.clone())
})
.collect::<Vec<_>>()
};
for tx in targets {
let _ = tx.send(msg.clone());
}
}
pub async fn disconnect_client(client_id: ClientId, state: Arc<RwLock<ChatState>>) {
let (old_room, old_name, token, was_logged_in) = {
let mut guard = state.write().await;
let Some(client) = guard.clients.remove(&client_id) else {
return;
};
if !client.room.is_empty() {
if let Some(members) = guard.rooms.get_mut(&client.room) {
members.remove(&client_id);
if members.is_empty() {
guard.rooms.remove(&client.room);
}
}
}
(client.room, client.user_name, client.token, client.logged_in)
};
if let Some(token) = token {
let mut guard = state.write().await;
guard.tokens.remove(&token);
if was_logged_in {
guard.logged_in_names.remove(&old_name);
}
}
if !old_room.is_empty() {
broadcast_room(
&old_room,
state,
json!({"type":"system","message": format!("{old_name} disconnected")}),
Some(client_id),
)
.await;
}
}

89
src/types.rs Normal file
View File

@@ -0,0 +1,89 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_postgres::Client as PgClient;
pub(crate) type ClientId = u64;
#[derive(Clone)]
pub(crate) struct ClientConn {
pub(crate) user_name: String,
pub(crate) room: String,
pub(crate) color: Option<String>,
pub(crate) token: Option<String>,
pub(crate) falukant_user_id: Option<i32>,
pub(crate) chat_user_id: Option<i32>,
pub(crate) age: Option<i32>,
pub(crate) rights: HashSet<String>,
pub(crate) logged_in: bool,
pub(crate) tx: mpsc::UnboundedSender<String>,
}
#[derive(Default)]
pub(crate) struct ChatState {
pub(crate) clients: HashMap<ClientId, ClientConn>,
pub(crate) rooms: HashMap<String, HashSet<ClientId>>,
pub(crate) tokens: HashMap<String, ClientId>,
pub(crate) logged_in_names: HashSet<String>,
pub(crate) dice_games: HashMap<String, DiceGame>,
pub(crate) room_meta: HashMap<String, RoomMeta>,
}
#[derive(Clone, Default)]
pub(crate) struct ServerConfig {
pub(crate) allowed_users: Option<HashSet<String>>,
pub(crate) db_client: Option<Arc<PgClient>>,
}
#[derive(Debug)]
pub(crate) struct UserProfile {
pub(crate) display_name: String,
pub(crate) color: Option<String>,
pub(crate) falukant_user_id: Option<i32>,
pub(crate) chat_user_id: Option<i32>,
pub(crate) age: Option<i32>,
pub(crate) rights: HashSet<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct Command {
#[serde(rename = "type")]
pub(crate) cmd_type: Value,
pub(crate) token: Option<String>,
pub(crate) name: Option<String>,
pub(crate) room: Option<String>,
pub(crate) message: Option<String>,
pub(crate) value: Option<Value>,
pub(crate) password: Option<String>,
pub(crate) rounds: Option<i32>,
pub(crate) to: Option<String>,
#[serde(rename = "userName")]
pub(crate) user_name: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct RoomInfo {
pub(crate) name: String,
pub(crate) users: usize,
}
#[derive(Clone, Default)]
pub(crate) struct DiceGame {
pub(crate) running: bool,
pub(crate) current_round: i32,
pub(crate) total_rounds: i32,
pub(crate) rolled_this_round: HashMap<ClientId, i32>,
pub(crate) total_scores: HashMap<ClientId, i32>,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct RoomMeta {
pub(crate) name: String,
pub(crate) password: Option<String>,
pub(crate) min_age: Option<i32>,
pub(crate) max_age: Option<i32>,
pub(crate) is_public: bool,
pub(crate) owner_id: Option<i32>,
}

18
yourchat2.service Normal file
View File

@@ -0,0 +1,18 @@
[Unit]
Description=yourchat2 Rust chat daemon
After=network.target
[Service]
Type=simple
User=torsten
WorkingDirectory=/home/torsten/Programs/yourchat2
Environment=CHAT_WS_ADDR=0.0.0.0:1235
Environment=CHAT_TCP_ADDR=127.0.0.1:1236
# Optional:
# Environment=CHAT_UNIX_SOCKET=/run/yourchat2/yourchat2.sock
ExecStart=/home/torsten/Programs/yourchat2/target/release/yourchat2
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target