Implement session replacement and WebSocket keepalive features
Enhanced the session management by allowing a reconnect with the same username to replace the existing session, sending a logout message to the previous session. Introduced WebSocket keepalive functionality using Ping/Pong messages to detect stale connections. Updated documentation to reflect these changes and improve user experience during reconnections.
This commit is contained in:
@@ -11,7 +11,8 @@ Die Kommunikation erfolgt ueber WebSocket (Frontend) und optional TCP/Unix-Socke
|
||||
- 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`)
|
||||
- Reconnect-freundlich: bestehende Session wird bei erneutem `init` mit gleichem Namen ersetzt
|
||||
- WebSocket Keepalive via Ping/Pong zur Erkennung verwaister Verbindungen
|
||||
- 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`
|
||||
|
||||
@@ -47,6 +47,12 @@ Sie ist absichtlich auf das Protokoll und die Client-Integration fokussiert (ohn
|
||||
{"type":5,"message":"room_entered","to":"lobby"}
|
||||
```
|
||||
|
||||
- Erzwungenes Logout bei Session-Ersatz (Reconnect mit gleichem Namen):
|
||||
|
||||
```json
|
||||
{"type":5,"message":"logout","reason":"replaced_by_new_login"}
|
||||
```
|
||||
|
||||
- Chatnachricht:
|
||||
|
||||
```json
|
||||
@@ -183,7 +189,6 @@ function sendMessage(text) {
|
||||
|
||||
- `missing_name`: `init` ohne `name`
|
||||
- `invalid_username`: Username entspricht nicht den Regeln
|
||||
- `loggedin`: Username ist bereits aktiv
|
||||
- `user_not_allowed`: User nicht erlaubt (DB/Allowlist)
|
||||
- `not_initialized`: Command vor `init`
|
||||
- `missing_token`: Token fehlt
|
||||
@@ -194,7 +199,7 @@ function sendMessage(text) {
|
||||
## Hinweise für Frontend-Implementierung
|
||||
|
||||
- Token zentral im Chat-State halten
|
||||
- Reconnect-Strategie einbauen (neues `init`, neues Token)
|
||||
- Reconnect-Strategie einbauen (neues `init`, neues Token); alte Session wird dabei serverseitig ersetzt
|
||||
- Vor Senden auth-pflichtiger Commands Token prüfen
|
||||
- UI sollte Fehler vom Typ `error` immer sichtbar machen
|
||||
- Für Slash-Kommandos reicht normales `message`-Senden
|
||||
|
||||
160
src/commands.rs
160
src/commands.rs
@@ -211,83 +211,95 @@ async fn handle_init_command(
|
||||
return;
|
||||
}
|
||||
|
||||
let (token, user_name, actual_room_name, old_room_name) = {
|
||||
let mut guard = state.write().await;
|
||||
if guard.logged_in_names.contains(&requested_user_name)
|
||||
&& guard
|
||||
.clients
|
||||
.iter()
|
||||
.any(|(id, c)| *id != client_id && c.logged_in && c.user_name == requested_user_name)
|
||||
{
|
||||
drop(guard);
|
||||
send_error(client_id, Arc::clone(&state), "loggedin").await;
|
||||
return;
|
||||
}
|
||||
if !guard.room_meta.contains_key(&resolved_room_name) {
|
||||
drop(guard);
|
||||
if room_debug_enabled() {
|
||||
eprintln!(
|
||||
"[yourchat2][room-debug][init] client_id={client_id} resolved_room='{resolved_room_name}' vanished_before_join"
|
||||
);
|
||||
let (token, user_name, actual_room_name, old_room_name) = loop {
|
||||
let replacement_needed = {
|
||||
let mut guard = state.write().await;
|
||||
if let Some(existing_client_id) = guard.clients.iter().find_map(|(id, c)| {
|
||||
if *id != client_id && c.logged_in && c.user_name == requested_user_name {
|
||||
Some(*id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
Some(existing_client_id)
|
||||
} else {
|
||||
if !guard.room_meta.contains_key(&resolved_room_name) {
|
||||
drop(guard);
|
||||
if room_debug_enabled() {
|
||||
eprintln!(
|
||||
"[yourchat2][room-debug][init] client_id={client_id} resolved_room='{resolved_room_name}' vanished_before_join"
|
||||
);
|
||||
}
|
||||
send_error(client_id, Arc::clone(&state), "room_not_found").await;
|
||||
return;
|
||||
}
|
||||
|
||||
let (old_room, old_name, was_logged_in, user_name, token, new_token) = {
|
||||
let Some(client) = guard.clients.get_mut(&client_id) else {
|
||||
return;
|
||||
};
|
||||
let old_room = client.room.clone();
|
||||
let old_name = client.user_name.clone();
|
||||
let was_logged_in = client.logged_in;
|
||||
|
||||
client.user_name = profile.display_name.clone();
|
||||
client.color = profile.color.clone();
|
||||
client.falukant_user_id = profile.falukant_user_id;
|
||||
client.chat_user_id = profile.chat_user_id;
|
||||
client.gender_id = profile.gender_id;
|
||||
client.age = profile.age;
|
||||
client.rights = profile.rights.clone();
|
||||
client.right_type_ids = profile.right_type_ids.clone();
|
||||
client.logged_in = true;
|
||||
client.room = resolved_room_name.clone();
|
||||
|
||||
let mut new_token = None;
|
||||
if client.token.is_none() {
|
||||
let generated = Uuid::new_v4().to_string();
|
||||
client.token = Some(generated.clone());
|
||||
new_token = Some(generated);
|
||||
}
|
||||
|
||||
(
|
||||
old_room,
|
||||
old_name,
|
||||
was_logged_in,
|
||||
client.user_name.clone(),
|
||||
client.token.clone().unwrap_or_default(),
|
||||
new_token,
|
||||
)
|
||||
};
|
||||
|
||||
if let Some(generated) = new_token {
|
||||
guard.tokens.insert(generated, client_id);
|
||||
}
|
||||
if was_logged_in {
|
||||
guard.logged_in_names.remove(&old_name);
|
||||
}
|
||||
guard.logged_in_names.insert(user_name.clone());
|
||||
if !old_room.is_empty() {
|
||||
if let Some(members) = guard.rooms.get_mut(&old_room) {
|
||||
members.remove(&client_id);
|
||||
}
|
||||
}
|
||||
guard
|
||||
.rooms
|
||||
.entry(resolved_room_name.clone())
|
||||
.or_default()
|
||||
.insert(client_id);
|
||||
break (token, user_name, resolved_room_name.clone(), old_room);
|
||||
}
|
||||
send_error(client_id, Arc::clone(&state), "room_not_found").await;
|
||||
return;
|
||||
}
|
||||
|
||||
let (old_room, old_name, was_logged_in, user_name, token, new_token) = {
|
||||
let Some(client) = guard.clients.get_mut(&client_id) else {
|
||||
return;
|
||||
};
|
||||
let old_room = client.room.clone();
|
||||
let old_name = client.user_name.clone();
|
||||
let was_logged_in = client.logged_in;
|
||||
|
||||
client.user_name = profile.display_name.clone();
|
||||
client.color = profile.color.clone();
|
||||
client.falukant_user_id = profile.falukant_user_id;
|
||||
client.chat_user_id = profile.chat_user_id;
|
||||
client.gender_id = profile.gender_id;
|
||||
client.age = profile.age;
|
||||
client.rights = profile.rights.clone();
|
||||
client.right_type_ids = profile.right_type_ids.clone();
|
||||
client.logged_in = true;
|
||||
client.room = resolved_room_name.clone();
|
||||
|
||||
let mut new_token = None;
|
||||
if client.token.is_none() {
|
||||
let generated = Uuid::new_v4().to_string();
|
||||
client.token = Some(generated.clone());
|
||||
new_token = Some(generated);
|
||||
}
|
||||
|
||||
(
|
||||
old_room,
|
||||
old_name,
|
||||
was_logged_in,
|
||||
client.user_name.clone(),
|
||||
client.token.clone().unwrap_or_default(),
|
||||
new_token,
|
||||
)
|
||||
};
|
||||
|
||||
if let Some(generated) = new_token {
|
||||
guard.tokens.insert(generated, client_id);
|
||||
if let Some(old_client_id) = replacement_needed {
|
||||
state::send_to_client(
|
||||
old_client_id,
|
||||
Arc::clone(&state),
|
||||
json!({"type":5, "message":"logout", "reason":"replaced_by_new_login"}),
|
||||
)
|
||||
.await;
|
||||
state::disconnect_client(old_client_id, Arc::clone(&state)).await;
|
||||
continue;
|
||||
}
|
||||
if was_logged_in {
|
||||
guard.logged_in_names.remove(&old_name);
|
||||
}
|
||||
guard.logged_in_names.insert(user_name.clone());
|
||||
if !old_room.is_empty() {
|
||||
if let Some(members) = guard.rooms.get_mut(&old_room) {
|
||||
members.remove(&client_id);
|
||||
}
|
||||
}
|
||||
guard
|
||||
.rooms
|
||||
.entry(resolved_room_name.clone())
|
||||
.or_default()
|
||||
.insert(client_id);
|
||||
(token, user_name, resolved_room_name, old_room)
|
||||
};
|
||||
if !old_room_name.is_empty() {
|
||||
state::mark_room_possibly_empty(&old_room_name, Arc::clone(&state)).await;
|
||||
|
||||
71
src/db.rs
71
src/db.rs
@@ -421,31 +421,52 @@ async fn load_user_gender(client: Arc<PgClient>, user_id: i32) -> Option<i32> {
|
||||
}
|
||||
|
||||
async fn load_user_gender_from_user_param(client: Arc<PgClient>, user_id: i32) -> Option<i32> {
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT
|
||||
up.value AS raw_value,
|
||||
tpv_id.value AS mapped_by_id_value,
|
||||
tpv_order.value AS mapped_by_order_value
|
||||
FROM community.user_param up
|
||||
JOIN \"type\".user_param tp
|
||||
ON up.param_type_id = tp.id
|
||||
LEFT JOIN \"type\".user_param_value tpv_id
|
||||
ON tpv_id.user_param_type_id = tp.id
|
||||
AND tpv_id.id::text = up.value
|
||||
LEFT JOIN \"type\".user_param_value tpv_order
|
||||
ON tpv_order.user_param_type_id = tp.id
|
||||
AND tpv_order.order_id::text = up.value
|
||||
WHERE up.user_id = $1
|
||||
AND tp.description IN ('gender', 'sex', 'geschlecht')
|
||||
AND up.value IS NOT NULL
|
||||
AND btrim(up.value) <> ''
|
||||
ORDER BY up.updated_at DESC, up.id DESC
|
||||
LIMIT 1",
|
||||
&[&user_id],
|
||||
)
|
||||
.await
|
||||
.ok()??;
|
||||
let mut row = None;
|
||||
for query in [
|
||||
"SELECT
|
||||
up.value::text AS raw_value,
|
||||
tpv_id.value AS mapped_by_id_value,
|
||||
tpv_order.value AS mapped_by_order_value
|
||||
FROM community.user_param up
|
||||
JOIN \"type\".user_param tp
|
||||
ON up.param_type_id = tp.id
|
||||
LEFT JOIN \"type\".user_param_value tpv_id
|
||||
ON tpv_id.user_param_type_id = tp.id
|
||||
AND tpv_id.id::text = up.value::text
|
||||
LEFT JOIN \"type\".user_param_value tpv_order
|
||||
ON tpv_order.user_param_type_id = tp.id
|
||||
AND tpv_order.order_id::text = up.value::text
|
||||
WHERE up.user_id = $1
|
||||
AND lower(btrim(tp.description)) IN ('gender', 'sex', 'geschlecht')
|
||||
AND up.value IS NOT NULL
|
||||
AND btrim(up.value::text) <> ''
|
||||
ORDER BY up.updated_at DESC NULLS LAST, up.id DESC
|
||||
LIMIT 1",
|
||||
"SELECT
|
||||
up.user_param_value_id::text AS raw_value,
|
||||
tpv_id.value AS mapped_by_id_value,
|
||||
tpv_order.value AS mapped_by_order_value
|
||||
FROM community.user_param up
|
||||
JOIN \"type\".user_param tp
|
||||
ON up.param_type_id = tp.id
|
||||
LEFT JOIN \"type\".user_param_value tpv_id
|
||||
ON tpv_id.user_param_type_id = tp.id
|
||||
AND tpv_id.id = up.user_param_value_id
|
||||
LEFT JOIN \"type\".user_param_value tpv_order
|
||||
ON tpv_order.user_param_type_id = tp.id
|
||||
AND tpv_order.order_id = up.user_param_value_id
|
||||
WHERE up.user_id = $1
|
||||
AND lower(btrim(tp.description)) IN ('gender', 'sex', 'geschlecht')
|
||||
AND up.user_param_value_id IS NOT NULL
|
||||
ORDER BY up.updated_at DESC NULLS LAST, up.id DESC
|
||||
LIMIT 1",
|
||||
] {
|
||||
row = client.query_opt(query, &[&user_id]).await.ok().flatten();
|
||||
if row.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let row = row?;
|
||||
|
||||
let raw_value = row.get::<_, Option<String>>("raw_value");
|
||||
let mapped_by_id_value = row.get::<_, Option<String>>("mapped_by_id_value");
|
||||
|
||||
43
src/main.rs
43
src/main.rs
@@ -9,7 +9,7 @@ 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::time::{Duration, interval};
|
||||
use tokio::time::{Duration, Instant, interval};
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer};
|
||||
use tokio_rustls::rustls::ServerConfig as RustlsServerConfig;
|
||||
@@ -395,13 +395,8 @@ where
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut ping_tick = interval(Duration::from_secs(20));
|
||||
let mut pending_ping_since: Option<Instant> = None;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -410,17 +405,46 @@ where
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = ping_tick.tick() => {
|
||||
if let Some(sent_at) = pending_ping_since {
|
||||
if sent_at.elapsed() > Duration::from_secs(10) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ws_write.send(Message::Ping(Vec::new().into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
pending_ping_since = Some(Instant::now());
|
||||
}
|
||||
outbound = rx.recv() => {
|
||||
let Some(msg) = outbound else {
|
||||
break;
|
||||
};
|
||||
if ws_write.send(Message::Text(msg.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
incoming = ws_read.next() => {
|
||||
match incoming {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
pending_ping_since = None;
|
||||
commands::process_text_command(client_id, &text, Arc::clone(&state), Arc::clone(&config)).await;
|
||||
}
|
||||
Some(Ok(Message::Binary(bin))) => {
|
||||
pending_ping_since = None;
|
||||
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::Ping(payload))) => {
|
||||
pending_ping_since = None;
|
||||
if ws_write.send(Message::Pong(payload)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Pong(_))) => {
|
||||
pending_ping_since = None;
|
||||
}
|
||||
Some(Ok(Message::Close(_))) => break,
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(_)) | None => break,
|
||||
@@ -430,7 +454,6 @@ where
|
||||
}
|
||||
|
||||
state::disconnect_client(client_id, state).await;
|
||||
writer_task.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user