diff --git a/crates/chattyness-db/Cargo.toml b/crates/chattyness-db/Cargo.toml index 4a6768d..be61afb 100644 --- a/crates/chattyness-db/Cargo.toml +++ b/crates/chattyness-db/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true chattyness-error = { workspace = true, optional = true } chattyness-shared = { workspace = true, optional = true } serde.workspace = true +serde_json = { workspace = true, optional = true } uuid.workspace = true chrono.workspace = true @@ -17,4 +18,4 @@ rand = { workspace = true, optional = true } [features] default = [] -ssr = ["sqlx", "argon2", "rand", "chattyness-error/ssr", "dep:chattyness-error", "dep:chattyness-shared"] +ssr = ["sqlx", "argon2", "rand", "chattyness-error/ssr", "dep:chattyness-error", "dep:chattyness-shared", "dep:serde_json"] diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 7655989..919709b 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -209,6 +209,42 @@ impl std::str::FromStr for RealmRole { } } +/// Moderation action type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr( + feature = "ssr", + sqlx(type_name = "action_type", rename_all = "snake_case") +)] +#[serde(rename_all = "snake_case")] +pub enum ActionType { + Warning, + Mute, + Kick, + Ban, + Unban, + PropRemoval, + MessageDeletion, + Summon, + SummonAll, +} + +impl std::fmt::Display for ActionType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ActionType::Warning => write!(f, "warning"), + ActionType::Mute => write!(f, "mute"), + ActionType::Kick => write!(f, "kick"), + ActionType::Ban => write!(f, "ban"), + ActionType::Unban => write!(f, "unban"), + ActionType::PropRemoval => write!(f, "prop_removal"), + ActionType::MessageDeletion => write!(f, "message_deletion"), + ActionType::Summon => write!(f, "summon"), + ActionType::SummonAll => write!(f, "summon_all"), + } + } +} + /// Scene dimension mode. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] diff --git a/crates/chattyness-db/src/queries.rs b/crates/chattyness-db/src/queries.rs index e06df7b..196db89 100644 --- a/crates/chattyness-db/src/queries.rs +++ b/crates/chattyness-db/src/queries.rs @@ -7,6 +7,7 @@ pub mod guests; pub mod inventory; pub mod loose_props; pub mod memberships; +pub mod moderation; pub mod owner; pub mod props; pub mod realms; diff --git a/crates/chattyness-db/src/queries/memberships.rs b/crates/chattyness-db/src/queries/memberships.rs index ab4ab21..212fe1e 100644 --- a/crates/chattyness-db/src/queries/memberships.rs +++ b/crates/chattyness-db/src/queries/memberships.rs @@ -199,3 +199,33 @@ pub async fn is_member(pool: &PgPool, user_id: Uuid, realm_id: Uuid) -> Result Result { + // Check server-level staff first (owner, admin, moderator can moderate any realm) + if let Some(server_role) = get_user_staff_role(pool, user_id).await? { + match server_role { + ServerRole::Owner | ServerRole::Admin | ServerRole::Moderator => return Ok(true), + } + } + + // Check realm-level role (owner or moderator for this specific realm) + let realm_mod: (bool,) = sqlx::query_as( + r#" + SELECT EXISTS( + SELECT 1 FROM realm.memberships + WHERE user_id = $1 AND realm_id = $2 AND role IN ('owner', 'moderator') + ) + "#, + ) + .bind(user_id) + .bind(realm_id) + .fetch_one(pool) + .await?; + + Ok(realm_mod.0) +} diff --git a/crates/chattyness-db/src/queries/moderation.rs b/crates/chattyness-db/src/queries/moderation.rs new file mode 100644 index 0000000..3ddc0fb --- /dev/null +++ b/crates/chattyness-db/src/queries/moderation.rs @@ -0,0 +1,77 @@ +//! Moderation-related database queries. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::models::ActionType; +use chattyness_error::AppError; + +/// Log a moderation action to the realm audit log. +pub async fn log_moderation_action( + pool: &PgPool, + realm_id: Uuid, + moderator_id: Uuid, + action_type: ActionType, + target_user_id: Option, + reason: &str, + metadata: serde_json::Value, +) -> Result<(), AppError> { + sqlx::query( + r#" + INSERT INTO realm.moderation_actions ( + realm_id, + action_type, + target_user_id, + moderator_id, + reason, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + ) + .bind(realm_id) + .bind(action_type) + .bind(target_user_id) + .bind(moderator_id) + .bind(reason) + .bind(metadata) + .execute(pool) + .await?; + + Ok(()) +} + +/// Log a moderation action using a connection (for RLS support). +pub async fn log_moderation_action_conn( + conn: &mut sqlx::PgConnection, + realm_id: Uuid, + moderator_id: Uuid, + action_type: ActionType, + target_user_id: Option, + reason: &str, + metadata: serde_json::Value, +) -> Result<(), AppError> { + sqlx::query( + r#" + INSERT INTO realm.moderation_actions ( + realm_id, + action_type, + target_user_id, + moderator_id, + reason, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + ) + .bind(realm_id) + .bind(action_type) + .bind(target_user_id) + .bind(moderator_id) + .bind(reason) + .bind(metadata) + .execute(conn) + .await?; + + Ok(()) +} diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 3dd17fc..350ca0f 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -90,6 +90,14 @@ pub enum ClientMessage { /// Scene ID to teleport to. scene_id: Uuid, }, + + /// Moderator command (only processed if sender is a moderator). + ModCommand { + /// Subcommand name ("summon", "avatar", "teleport", "ban", etc.). + subcommand: String, + /// Arguments for the subcommand. + args: Vec, + }, } /// Server-to-client WebSocket messages. @@ -234,4 +242,22 @@ pub enum ServerMessage { /// Scene slug for URL. scene_slug: String, }, + + /// User has been summoned by a moderator - triggers teleport. + Summoned { + /// Scene ID to teleport to. + scene_id: Uuid, + /// Scene slug for URL. + scene_slug: String, + /// Display name of the moderator who summoned. + summoned_by: String, + }, + + /// Result of a moderator command. + ModCommandResult { + /// Whether the command succeeded. + success: bool, + /// Human-readable result message. + message: String, + }, } diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 14766e1..1f6f54c 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -18,8 +18,8 @@ use tokio::sync::{broadcast, mpsc}; use uuid::Uuid; use chattyness_db::{ - models::{AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User}, - queries::{avatars, channel_members, loose_props, realms, scenes}, + models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User}, + queries::{avatars, channel_members, loose_props, memberships, moderation, realms, scenes}, ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig}, }; use chattyness_error::AppError; @@ -828,6 +828,169 @@ async fn handle_socket( scene_slug: scene.slug, }).await; } + ClientMessage::ModCommand { subcommand, args } => { + // Check if user is a moderator + let is_mod = match memberships::is_moderator(&pool, user_id, realm_id).await { + Ok(result) => result, + Err(e) => { + tracing::error!("[WS] Failed to check moderator status: {:?}", e); + false + } + }; + + if !is_mod { + let _ = direct_tx.send(ServerMessage::ModCommandResult { + success: false, + message: "You do not have moderator permissions".to_string(), + }).await; + continue; + } + + // Get moderator's current scene info and display name + let mod_member = match channel_members::get_channel_member( + &mut *recv_conn, + channel_id, + user_id, + realm_id, + ).await { + Ok(Some(m)) => m, + Ok(None) | Err(_) => { + let _ = direct_tx.send(ServerMessage::ModCommandResult { + success: false, + message: "Failed to get moderator info".to_string(), + }).await; + continue; + } + }; + + // Get moderator's current scene details + let mod_scene = match scenes::get_scene_by_id(&pool, channel_id).await { + Ok(Some(s)) => s, + Ok(None) | Err(_) => { + let _ = direct_tx.send(ServerMessage::ModCommandResult { + success: false, + message: "Failed to get scene info".to_string(), + }).await; + continue; + } + }; + + match subcommand.as_str() { + "summon" => { + if args.is_empty() { + let _ = direct_tx.send(ServerMessage::ModCommandResult { + success: false, + message: "Usage: /mod summon [nick|*]".to_string(), + }).await; + continue; + } + + let target = &args[0]; + + if target == "*" { + // Summon all users in the realm + let mut summoned_count = 0; + let mut target_ids = Vec::new(); + + // Iterate all connected users in this realm + for entry in ws_state.users.iter() { + let (target_user_id, target_conn) = entry.pair(); + if target_conn.realm_id == realm_id && *target_user_id != user_id { + // Send Summoned message to each user + let summon_msg = ServerMessage::Summoned { + scene_id: mod_scene.id, + scene_slug: mod_scene.slug.clone(), + summoned_by: mod_member.display_name.clone(), + }; + if target_conn.direct_tx.send(summon_msg).await.is_ok() { + summoned_count += 1; + target_ids.push(*target_user_id); + } + } + } + + // Log the action + let metadata = serde_json::json!({ + "scene_id": mod_scene.id, + "scene_slug": mod_scene.slug, + "summoned_count": summoned_count, + }); + let _ = moderation::log_moderation_action( + &pool, + realm_id, + user_id, + ActionType::SummonAll, + None, + &format!("Summoned {} users to scene {}", summoned_count, mod_scene.name), + metadata, + ).await; + + let _ = direct_tx.send(ServerMessage::ModCommandResult { + success: true, + message: format!("Summoned {} users to {}", summoned_count, mod_scene.name), + }).await; + } else { + // Summon specific user by display name + if let Some((target_user_id, target_conn)) = ws_state + .find_user_by_display_name(realm_id, target) + { + if target_user_id == user_id { + let _ = direct_tx.send(ServerMessage::ModCommandResult { + success: false, + message: "You cannot summon yourself".to_string(), + }).await; + continue; + } + + // Send Summoned message to target + let summon_msg = ServerMessage::Summoned { + scene_id: mod_scene.id, + scene_slug: mod_scene.slug.clone(), + summoned_by: mod_member.display_name.clone(), + }; + if target_conn.direct_tx.send(summon_msg).await.is_ok() { + // Log the action + let metadata = serde_json::json!({ + "scene_id": mod_scene.id, + "scene_slug": mod_scene.slug, + "target_display_name": target, + }); + let _ = moderation::log_moderation_action( + &pool, + realm_id, + user_id, + ActionType::Summon, + Some(target_user_id), + &format!("Summoned {} to scene {}", target, mod_scene.name), + metadata, + ).await; + + let _ = direct_tx.send(ServerMessage::ModCommandResult { + success: true, + message: format!("Summoned {} to {}", target, mod_scene.name), + }).await; + } else { + let _ = direct_tx.send(ServerMessage::ModCommandResult { + success: false, + message: format!("Failed to send summon to {}", target), + }).await; + } + } else { + let _ = direct_tx.send(ServerMessage::ModCommandResult { + success: false, + message: format!("User '{}' is not online in this realm", target), + }).await; + } + } + } + _ => { + let _ = direct_tx.send(ServerMessage::ModCommandResult { + success: false, + message: format!("Unknown mod command: {}", subcommand), + }).await; + } + } + } } } Message::Close(close_frame) => { diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 1ea4738..ec0a6d5 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -19,6 +19,8 @@ enum CommandMode { ShowingColonHint, /// Showing command hint for slash commands (`/setting`). ShowingSlashHint, + /// Showing mod command hint (`/mod summon [nick|*]`). + ShowingModHint, /// Showing emotion list popup. ShowingList, /// Showing scene list popup for teleport. @@ -98,6 +100,35 @@ fn parse_whisper_command(cmd: &str) -> Option<(String, String)> { Some((name, message)) } +/// Parse a mod command and return (subcommand, args) if valid. +/// +/// Supports `/mod summon [nick|*]` etc. +#[cfg(feature = "hydrate")] +fn parse_mod_command(cmd: &str) -> Option<(String, Vec)> { + let cmd = cmd.trim(); + + // Strip the leading slash if present + let cmd = cmd.strip_prefix('/').unwrap_or(cmd); + + // Check for `mod [args...]` + let rest = cmd.strip_prefix("mod ").map(str::trim)?; + + if rest.is_empty() { + return None; + } + + // Split into parts + let parts: Vec<&str> = rest.split_whitespace().collect(); + if parts.is_empty() { + return None; + } + + let subcommand = parts[0].to_lowercase(); + let args: Vec = parts[1..].iter().map(|s| s.to_string()).collect(); + + Some((subcommand, args)) +} + /// Chat input component with emote command support. /// /// Props: @@ -140,6 +171,12 @@ pub fn ChatInput( /// Callback when a teleport is requested. #[prop(optional)] on_teleport: Option>, + /// Whether the current user is a moderator. + #[prop(default = Signal::derive(|| false))] + is_moderator: Signal, + /// Callback to send a mod command. + #[prop(optional)] + on_mod_command: Option)>>, ) -> impl IntoView { let (message, set_message) = signal(String::new()); let (command_mode, set_command_mode) = signal(CommandMode::None); @@ -324,9 +361,16 @@ pub fn ChatInput( } }; + // Check if mod command (only for moderators) + let is_mod_command = is_moderator.get_untracked() + && (cmd.starts_with("mod") || "mod".starts_with(&cmd)); + if is_complete_whisper || is_complete_teleport { // User is typing the argument part, no hint needed set_command_mode.set(CommandMode::None); + } else if is_mod_command { + // Show mod command hint + set_command_mode.set(CommandMode::ShowingModHint); } else if cmd.is_empty() || "setting".starts_with(&cmd) || "inventory".starts_with(&cmd) @@ -625,6 +669,22 @@ pub fn ChatInput( return; } + // /mod [args...] - execute mod command + if is_moderator.get_untracked() { + if let Some((subcommand, args)) = parse_mod_command(&msg) { + if let Some(ref callback) = on_mod_command { + callback.run((subcommand, args)); + } + set_message.set(String::new()); + set_command_mode.set(CommandMode::None); + if let Some(input) = input_ref.get() { + input.set_value(""); + } + ev.prevent_default(); + return; + } + } + // Invalid slash command - just ignore, don't send ev.prevent_default(); return; @@ -769,6 +829,17 @@ pub fn ChatInput( + // Mod command hint bar (/mod summon [nick|*]) + +
+ "[MOD] " + "/" + "mod" + " summon" + " [nick|*]" +
+
+ // Emotion list popup >, on_error: Option>, on_teleport_approved: Option>, + on_summoned: Option>, + on_mod_command_result: Option>, ) -> (Signal, WsSenderStorage) { use std::cell::RefCell; use std::rc::Rc; @@ -220,6 +242,8 @@ pub fn use_channel_websocket( let on_welcome_clone = on_welcome.clone(); let on_error_clone = on_error.clone(); let on_teleport_approved_clone = on_teleport_approved.clone(); + let on_summoned_clone = on_summoned.clone(); + let on_mod_command_result_clone = on_mod_command_result.clone(); // For starting heartbeat on Welcome let ws_ref_for_heartbeat = ws_ref.clone(); let heartbeat_started: Rc> = Rc::new(RefCell::new(false)); @@ -293,6 +317,8 @@ pub fn use_channel_websocket( &on_member_fading_clone, &on_error_clone, &on_teleport_approved_clone, + &on_summoned_clone, + &on_mod_command_result_clone, ¤t_user_id_for_msg, ); } @@ -402,6 +428,8 @@ fn handle_server_message( on_member_fading: &Callback, on_error: &Option>, on_teleport_approved: &Option>, + on_summoned: &Option>, + on_mod_command_result: &Option>, current_user_id: &std::rc::Rc>>, ) { let mut members_vec = members.borrow_mut(); @@ -578,6 +606,24 @@ fn handle_server_message( }); } } + ServerMessage::Summoned { + scene_id, + scene_slug, + summoned_by, + } => { + if let Some(callback) = on_summoned { + callback.run(SummonInfo { + scene_id, + scene_slug, + summoned_by, + }); + } + } + ServerMessage::ModCommandResult { success, message } => { + if let Some(callback) = on_mod_command_result { + callback.run(ModCommandResultInfo { success, message }); + } + } } } @@ -596,6 +642,8 @@ pub fn use_channel_websocket( _on_welcome: Option>, _on_error: Option>, _on_teleport_approved: Option>, + _on_summoned: Option>, + _on_mod_command_result: Option>, ) -> (Signal, WsSenderStorage) { let (ws_state, _) = signal(WsState::Disconnected); let sender: WsSenderStorage = StoredValue::new_local(None); diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 3fe2a94..c905111 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -20,7 +20,7 @@ use crate::components::{ #[cfg(feature = "hydrate")] use crate::components::{ ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry, - TeleportInfo, WsError, add_to_history, use_channel_websocket, + ModCommandResultInfo, SummonInfo, TeleportInfo, WsError, add_to_history, use_channel_websocket, }; use crate::utils::LocalStoragePersist; #[cfg(feature = "hydrate")] @@ -141,6 +141,12 @@ pub fn RealmPage() -> impl IntoView { // Whether teleportation is allowed in this realm let (allow_user_teleport, set_allow_user_teleport) = signal(false); + // Whether the current user is a moderator (set from Welcome message or membership) + let (is_moderator, set_is_moderator) = signal(false); + + // Mod notification state (for summon notifications, command results) + let (mod_notification, set_mod_notification) = signal(Option::<(bool, String)>::None); + let realm_data = LocalResource::new(move || { let slug = slug.get(); async move { @@ -418,6 +424,90 @@ pub fn RealmPage() -> impl IntoView { }); }); + // Callback for being summoned by a moderator - show notification and teleport + #[cfg(feature = "hydrate")] + let on_summoned = Callback::new(move |info: SummonInfo| { + // Show notification + set_mod_notification.set(Some((true, format!("Summoned by {}", info.summoned_by)))); + + // Auto-dismiss notification after 3 seconds + let timeout = gloo_timers::callback::Timeout::new(3000, move || { + set_mod_notification.set(None); + }); + timeout.forget(); + + let scene_id = info.scene_id; + let scene_slug = info.scene_slug.clone(); + let realm_slug = slug.get_untracked(); + + // Fetch the new scene data (same as teleport approval) + let scene_slug_for_url = scene_slug.clone(); + let realm_slug_for_url = realm_slug.clone(); + spawn_local(async move { + use gloo_net::http::Request; + let response = Request::get(&format!( + "/api/realms/{}/scenes/{}", + realm_slug, scene_slug + )) + .send() + .await; + + if let Ok(resp) = response { + if resp.ok() { + if let Ok(scene) = resp.json::().await { + // Update scene dimensions from the new scene + if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) { + set_scene_dimensions.set((w as f64, h as f64)); + } + + // Update URL to reflect new scene + if let Some(window) = web_sys::window() { + if let Ok(history) = window.history() { + let new_url = if scene.is_entry_point { + format!("/realms/{}", realm_slug_for_url) + } else { + format!( + "/realms/{}/scenes/{}", + realm_slug_for_url, scene_slug_for_url + ) + }; + let _ = history.replace_state_with_url( + &wasm_bindgen::JsValue::NULL, + "", + Some(&new_url), + ); + } + } + + // Update the current scene for the viewer + set_current_scene.set(Some(scene)); + } + } + } + + // Update channel_id to trigger WebSocket reconnection + set_channel_id.set(Some(scene_id)); + + // Clear members since we're switching scenes + set_members.set(Vec::new()); + + // Trigger a reconnect to ensure fresh connection + reconnect_trigger.update(|t| *t += 1); + }); + }); + + // Callback for mod command result - show notification + #[cfg(feature = "hydrate")] + let on_mod_command_result = Callback::new(move |info: ModCommandResultInfo| { + set_mod_notification.set(Some((info.success, info.message))); + + // Auto-dismiss notification after 3 seconds + let timeout = gloo_timers::callback::Timeout::new(3000, move || { + set_mod_notification.set(None); + }); + timeout.forget(); + }); + #[cfg(feature = "hydrate")] let (ws_state, ws_sender) = use_channel_websocket( slug, @@ -432,6 +522,8 @@ pub fn RealmPage() -> impl IntoView { Some(on_welcome), Some(on_ws_error), Some(on_teleport_approved), + Some(on_summoned), + Some(on_mod_command_result), ); // Set channel ID, current scene, and scene dimensions when entry scene loads @@ -955,6 +1047,9 @@ pub fn RealmPage() -> impl IntoView { Some(RealmRole::Owner) | Some(RealmRole::Moderator) ); + // Update is_moderator signal for mod commands + set_is_moderator.set(can_admin); + // Get scene name and description for header let scene_info = entry_scene .get() @@ -1046,6 +1141,17 @@ pub fn RealmPage() -> impl IntoView { } }); }); + #[cfg(feature = "hydrate")] + let ws_for_mod = ws_sender_clone.clone(); + let on_mod_command_cb = Callback::new(move |(subcommand, args): (String, Vec)| { + #[cfg(feature = "hydrate")] + ws_for_mod.with_value(|sender| { + if let Some(send_fn) = sender { + send_fn(ClientMessage::ModCommand { subcommand, args }); + } + }); + }); + let is_moderator_signal = Signal::derive(move || is_moderator.get()); view! {
impl IntoView { scenes=scenes_signal allow_user_teleport=teleport_enabled_signal on_teleport=on_teleport_cb + is_moderator=is_moderator_signal + on_mod_command=on_mod_command_cb />
@@ -1223,6 +1331,36 @@ pub fn RealmPage() -> impl IntoView { }}
+ // Mod command notification toast (summon, command results) + + {move || { + if let Some((success, msg)) = mod_notification.get() { + let (bg_class, border_class, icon_class, icon) = if success { + ("bg-purple-900/90", "border-purple-500/50", "text-purple-300", "✓") + } else { + ("bg-yellow-900/90", "border-yellow-500/50", "text-yellow-300", "⚠") + }; + view! { +
+
+ "[MOD]" + {icon} + {msg} + +
+
+ }.into_any() + } else { + ().into_any() + } + }} +
+ // Notification history modal