diff --git a/src/commands.rs b/src/commands.rs index a517603..0a6b94a 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -3,6 +3,7 @@ use bcrypt::verify as bcrypt_verify; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; use tokio::sync::RwLock; use uuid::Uuid; @@ -100,6 +101,10 @@ pub async fn handle_command( handle_reload_rooms_command(client_id, ¤t, Arc::clone(&state), Arc::clone(&config)).await; return; } + "create_room" => { + handle_create_room_command(client_id, ¤t, Arc::clone(&state), Arc::clone(&config)).await; + return; + } _ => { handle_unknown_command(client_id, ¤t, Arc::clone(&state)).await; return; @@ -176,7 +181,7 @@ async fn handle_init_command( return; } - let (token, user_name, actual_room_name) = { + 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 @@ -247,8 +252,12 @@ async fn handle_init_command( .entry(resolved_room_name.clone()) .or_default() .insert(client_id); - (token, user_name, resolved_room_name) + (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; + } + state::mark_room_occupied(&actual_room_name, Arc::clone(&state)).await; state::send_to_client( client_id, @@ -343,6 +352,10 @@ async fn handle_join_command( (from_room, user_name, resolved_room) }; + if !from_room.is_empty() { + state::mark_room_possibly_empty(&from_room, Arc::clone(&state)).await; + } + state::mark_room_occupied(&actual_room_name, Arc::clone(&state)).await; if !from_room.is_empty() && from_room != room { state::broadcast_room( &from_room, @@ -793,6 +806,98 @@ async fn handle_reload_rooms_command( .await; } +#[derive(Debug, Clone)] +struct CreateRoomArgs { + room_name: String, + is_public: bool, + gender_restriction_id: Option, + min_age: Option, + max_age: Option, + password: Option, + friends_of_owner_only: bool, + required_user_right_id: Option, + room_type_id: Option, +} + +async fn handle_create_room_command( + client_id: ClientId, + command: &Command, + state: Arc>, + _config: Arc, +) { + if !state::authorize(client_id, command, Arc::clone(&state)).await { + return; + } + let raw = command + .message + .clone() + .or_else(|| string_from_value(command.value.as_ref())) + .unwrap_or_default(); + let parsed = match parse_create_room_args(&raw) { + Ok(v) => v, + Err(msg) => { + send_error(client_id, Arc::clone(&state), msg).await; + return; + } + }; + + let owner_chat_user_id = { + let guard = state.read().await; + let Some(client) = guard.clients.get(&client_id) else { + return; + }; + let Some(chat_user_id) = client.chat_user_id else { + send_error(client_id, Arc::clone(&state), "missing_chat_user_for_room_creation").await; + return; + }; + chat_user_id + }; + { + let mut guard = state.write().await; + if resolve_room_name(&guard, &parsed.room_name).is_some() { + drop(guard); + send_error(client_id, Arc::clone(&state), "room_already_exists").await; + return; + } + guard.rooms.entry(parsed.room_name.clone()).or_default(); + guard.room_meta.insert( + parsed.room_name.clone(), + RoomMeta { + name: parsed.room_name.clone(), + password: parsed.password.clone(), + gender_restriction_id: parsed.gender_restriction_id, + required_user_right_id: parsed.required_user_right_id, + min_age: parsed.min_age, + max_age: parsed.max_age, + is_public: parsed.is_public, + owner_id: Some(owner_chat_user_id), + room_type_id: parsed.room_type_id, + friends_of_owner_only: parsed.friends_of_owner_only, + is_temporary: true, + created_at_unix: Some(now_unix()), + empty_since_unix: Some(now_unix()), + }, + ); + } + state::send_room_list(client_id, Arc::clone(&state)).await; + state::send_to_client( + client_id, + Arc::clone(&state), + json!({ + "type":"system", + "message":"room_created", + "room": parsed.room_name, + "is_public": parsed.is_public, + "friends_of_owner_only": parsed.friends_of_owner_only, + "gender": parsed.gender_restriction_id.map(|v| if v == 1 { "m" } else { "f" }), + "min_age": parsed.min_age, + "max_age": parsed.max_age, + "required_user_right_id": parsed.required_user_right_id + }), + ) + .await; +} + async fn handle_unknown_command(client_id: ClientId, command: &Command, state: Arc>) { if command.password.is_some() { send_error( @@ -960,6 +1065,120 @@ fn normalize_color(input: &str) -> String { } } +fn parse_create_room_args(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("missing_room_name"); + } + let mut parts = trimmed.split_whitespace(); + let room_name = parts.next().unwrap_or_default().trim().to_string(); + if room_name.is_empty() { + return Err("missing_room_name"); + } + if room_name.len() > 64 { + return Err("room_name_too_long"); + } + let mut parsed = CreateRoomArgs { + room_name, + is_public: true, + gender_restriction_id: None, + min_age: None, + max_age: None, + password: None, + friends_of_owner_only: false, + required_user_right_id: None, + room_type_id: None, + }; + + for token in parts { + let lower = token.to_lowercase(); + if lower == "public" { + parsed.is_public = true; + continue; + } + if lower == "private" { + parsed.is_public = false; + continue; + } + if lower == "friends" || lower == "friends_only" { + parsed.friends_of_owner_only = true; + continue; + } + if lower == "nofriends" || lower == "no_friends" { + parsed.friends_of_owner_only = false; + continue; + } + let Some((key, value_raw)) = token.split_once('=') else { + return Err("invalid_create_room_param"); + }; + let key = key.trim().to_lowercase(); + let value = value_raw.trim(); + if value.is_empty() { + continue; + } + match key.as_str() { + "public" | "is_public" => { + let b = parse_bool(value).ok_or("invalid_boolean_param")?; + parsed.is_public = b; + } + "friends_only" | "friends_of_owner_only" | "friends" => { + let b = parse_bool(value).ok_or("invalid_boolean_param")?; + parsed.friends_of_owner_only = b; + } + "gender" | "g" => { + parsed.gender_restriction_id = parse_gender_mf(value).ok_or("invalid_gender_param_use_m_or_f")?; + } + "min_age" | "min" => { + parsed.min_age = Some(value.parse::().map_err(|_| "invalid_min_age")?); + } + "max_age" | "max" => { + parsed.max_age = Some(value.parse::().map_err(|_| "invalid_max_age")?); + } + "password" | "pw" => { + parsed.password = Some(value.to_string()); + } + "right" | "right_id" | "required_right" | "required_user_right_id" => { + parsed.required_user_right_id = Some(value.parse::().map_err(|_| "invalid_required_right_id")?); + } + "type" | "type_id" | "room_type" | "room_type_id" => { + parsed.room_type_id = Some(value.parse::().map_err(|_| "invalid_room_type_id")?); + } + _ => return Err("unknown_create_room_param"), + } + } + + if let (Some(min_age), Some(max_age)) = (parsed.min_age, parsed.max_age) { + if min_age > max_age { + return Err("invalid_age_range"); + } + } + Ok(parsed) +} + +fn parse_bool(value: &str) -> Option { + match value.trim().to_lowercase().as_str() { + "1" | "true" | "yes" | "y" | "on" => Some(true), + "0" | "false" | "no" | "n" | "off" => Some(false), + _ => None, + } +} + +fn parse_gender_mf(value: &str) -> Option> { + match value.trim().to_lowercase().as_str() { + "m" | "male" => Some(Some(1)), + "f" | "female" => Some(Some(2)), + "any" | "none" | "-" => Some(None), + _ => None, + } +} + +fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + fn command_from_chat_line(message: &str, token: Option) -> Option { let trimmed = message.trim(); if !trimmed.starts_with('/') { @@ -1061,6 +1280,18 @@ fn command_from_chat_line(message: &str, token: Option) -> Option Some(Command { + cmd_type: Value::String("create_room".to_string()), + token, + name: None, + room: None, + message: Some(rest), + value: None, + password: None, + rounds: None, + to: None, + user_name: None, + }), "/do" => Some(Command { cmd_type: Value::String("do".to_string()), token, diff --git a/src/db.rs b/src/db.rs index c51ddb4..d775d83 100644 --- a/src/db.rs +++ b/src/db.rs @@ -221,6 +221,9 @@ pub async fn load_room_configs(config: &ServerConfig) -> Result, & owner_id: row.get::<_, Option>("owner_id"), room_type_id: row.get::<_, Option>("room_type_id"), friends_of_owner_only: row.get::<_, bool>("friends_of_owner_only"), + is_temporary: false, + created_at_unix: None, + empty_since_unix: None, }); } diff --git a/src/main.rs b/src/main.rs index 8caf6dd..d76ea36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +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_rustls::TlsAcceptor; use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; use tokio_rustls::rustls::ServerConfig as RustlsServerConfig; @@ -58,6 +59,26 @@ async fn main() -> Result<(), Box> { } let next_client_id = Arc::new(AtomicU64::new(1)); let (shutdown_tx, shutdown_rx) = watch::channel(false); + let cleanup_state = Arc::clone(&state); + let mut cleanup_shutdown_rx = shutdown_rx.clone(); + let cleanup_task = tokio::spawn(async move { + let mut ticker = interval(Duration::from_secs(60)); + loop { + tokio::select! { + changed = cleanup_shutdown_rx.changed() => { + if changed.is_ok() && *cleanup_shutdown_rx.borrow() { + break; + } + } + _ = ticker.tick() => { + let removed = state::cleanup_stale_temporary_rooms(Arc::clone(&cleanup_state), 15 * 60).await; + if removed > 0 { + println!("[yourchat2] removed {removed} stale temporary room(s)"); + } + } + } + } + }); let ws_listener = TcpListener::bind(&ws_addr).await?; if ws_tls { @@ -205,6 +226,7 @@ async fn main() -> Result<(), Box> { let _ = ws_task.await; let _ = tcp_task.await; + let _ = cleanup_task.await; if let Some(task) = unix_task { let _ = task.await; if let Some(path) = unix_socket { diff --git a/src/state.rs b/src/state.rs index 55d1512..7ce2768 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,6 +1,7 @@ use crate::types::{ChatState, ClientId, Command, RoomInfo}; use serde_json::{json, Value}; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; use tokio::sync::RwLock; pub async fn authorize(client_id: ClientId, command: &Command, state: Arc>) -> bool { @@ -31,12 +32,19 @@ pub async fn authorize(client_id: ClientId, command: &Command, state: Arc>) { let list = { let guard = state.read().await; - let mut rooms: Vec = guard - .rooms - .iter() - .map(|(name, members)| RoomInfo { - name: name.clone(), - users: members.len(), + let mut names = guard.room_meta.keys().cloned().collect::>(); + for name in guard.rooms.keys() { + if !guard.room_meta.contains_key(name) { + names.push(name.clone()); + } + } + names.sort(); + names.dedup(); + let mut rooms: Vec = names + .into_iter() + .map(|name| RoomInfo { + users: guard.rooms.get(&name).map(|members| members.len()).unwrap_or(0), + name, }) .collect(); rooms.sort_by(|a, b| a.name.cmp(&b.name)); @@ -126,7 +134,20 @@ pub async fn disconnect_client(client_id: ClientId, state: Arc if let Some(members) = guard.rooms.get_mut(&client.room) { members.remove(&client_id); if members.is_empty() { - guard.rooms.remove(&client.room); + let is_temporary = guard + .room_meta + .get(&client.room) + .map(|meta| meta.is_temporary) + .unwrap_or(false); + if is_temporary { + if let Some(meta) = guard.room_meta.get_mut(&client.room) { + if meta.empty_since_unix.is_none() { + meta.empty_since_unix = Some(now_unix()); + } + } + } else { + guard.rooms.remove(&client.room); + } } } } @@ -152,3 +173,56 @@ pub async fn disconnect_client(client_id: ClientId, state: Arc .await; } } + +pub async fn mark_room_occupied(room_name: &str, state: Arc>) { + let mut guard = state.write().await; + if let Some(meta) = guard.room_meta.get_mut(room_name) { + meta.empty_since_unix = None; + } +} + +pub async fn mark_room_possibly_empty(room_name: &str, state: Arc>) { + let mut guard = state.write().await; + let is_empty = guard.rooms.get(room_name).map(|members| members.is_empty()).unwrap_or(true); + if !is_empty { + return; + } + if let Some(meta) = guard.room_meta.get_mut(room_name) { + if meta.is_temporary && meta.empty_since_unix.is_none() { + meta.empty_since_unix = Some(now_unix()); + } + } +} + +pub async fn cleanup_stale_temporary_rooms(state: Arc>, max_empty_seconds: i64) -> usize { + let mut guard = state.write().await; + let now = now_unix(); + let to_remove = guard + .room_meta + .iter() + .filter_map(|(name, meta)| { + if !meta.is_temporary { + return None; + } + let empty_since = meta.empty_since_unix?; + if now - empty_since >= max_empty_seconds { + Some(name.clone()) + } else { + None + } + }) + .collect::>(); + for room in &to_remove { + guard.room_meta.remove(room); + guard.rooms.remove(room); + guard.dice_games.remove(room); + } + to_remove.len() +} + +fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} diff --git a/src/types.rs b/src/types.rs index f62fb1e..13cd98d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -95,4 +95,7 @@ pub(crate) struct RoomMeta { pub(crate) owner_id: Option, pub(crate) room_type_id: Option, pub(crate) friends_of_owner_only: bool, + pub(crate) is_temporary: bool, + pub(crate) created_at_unix: Option, + pub(crate) empty_since_unix: Option, }