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:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
1438
Cargo.lock
generated
Normal file
1438
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
Normal file
16
Cargo.toml
Normal 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
117
README.md
Normal 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
1021
src/commands.rs
Normal file
File diff suppressed because it is too large
Load Diff
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()
|
||||
}
|
||||
321
src/main.rs
Normal file
321
src/main.rs
Normal 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
154
src/state.rs
Normal 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
89
src/types.rs
Normal 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
18
yourchat2.service
Normal 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
|
||||
Reference in New Issue
Block a user