From fac9e090cdebbd21c3b282ed04c61d0b8c86ebdd Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 20:32:02 +0100 Subject: [PATCH 01/23] feat: Add suspension helper to user service --- src/service/users/mod.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/service/users/mod.rs b/src/service/users/mod.rs index 701561a8..1a9f6600 100644 --- a/src/service/users/mod.rs +++ b/src/service/users/mod.rs @@ -15,6 +15,7 @@ use ruma::{ AnyToDeviceEvent, GlobalAccountDataEventType, ignored_user_list::IgnoredUserListEvent, }, serde::Raw, + uint, }; use serde_json::json; @@ -52,6 +53,7 @@ struct Data { userid_lastonetimekeyupdate: Arc, userid_masterkeyid: Arc, userid_password: Arc, + userid_suspended: Arc, userid_selfsigningkeyid: Arc, userid_usersigningkeyid: Arc, useridprofilekey_value: Arc, @@ -87,6 +89,7 @@ impl crate::Service for Service { userid_lastonetimekeyupdate: args.db["userid_lastonetimekeyupdate"].clone(), userid_masterkeyid: args.db["userid_masterkeyid"].clone(), userid_password: args.db["userid_password"].clone(), + userid_suspended: args.db["userid_suspended"].clone(), userid_selfsigningkeyid: args.db["userid_selfsigningkeyid"].clone(), userid_usersigningkeyid: args.db["userid_usersigningkeyid"].clone(), useridprofilekey_value: args.db["useridprofilekey_value"].clone(), @@ -143,6 +146,16 @@ impl Service { Ok(()) } + /// Suspend account, placing it in a read-only state + pub async fn suspend_account(&self, user_id: &UserId) -> () { + self.db.userid_suspended.insert(user_id, "1"); + } + + /// Unsuspend account, placing it in a read-write state + pub async fn unsuspend_account(&self, user_id: &UserId) -> () { + self.db.userid_suspended.remove(user_id); + } + /// Check if a user has an account on this homeserver. #[inline] pub async fn exists(&self, user_id: &UserId) -> bool { @@ -159,6 +172,16 @@ impl Service { .await } + /// Check if account is suspended + pub async fn is_suspended(&self, user_id: &UserId) -> Result { + self.db + .userid_suspended + .get(user_id) + .map_ok(|val| val.is_empty()) + .map_err(|_| err!(Request(NotFound("User does not exist.")))) + .await + } + /// Check if account is active, infallible pub async fn is_active(&self, user_id: &UserId) -> bool { !self.is_deactivated(user_id).await.unwrap_or(true) From accfda258638d7eef9b2c35c15e2b1658a478743 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 20:35:58 +0100 Subject: [PATCH 02/23] feat: Prevent suspended users sending events --- src/api/client/send.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/api/client/send.rs b/src/api/client/send.rs index f753fa65..b87d1822 100644 --- a/src/api/client/send.rs +++ b/src/api/client/send.rs @@ -23,6 +23,9 @@ pub(crate) async fn send_message_event_route( let sender_user = body.sender_user(); let sender_device = body.sender_device.as_deref(); let appservice_info = body.appservice_info.as_ref(); + if services.users.is_suspended(sender_user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } // Forbid m.room.encrypted if encryption is disabled if MessageLikeEventType::RoomEncrypted == body.event_type && !services.config.allow_encryption From 286974cb9a676bb58501835fbdf1fce08b3d9c6c Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 20:37:09 +0100 Subject: [PATCH 03/23] feat: Prevent suspended users redacting events --- src/api/client/redact.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/api/client/redact.rs b/src/api/client/redact.rs index 8dbe47a6..a8eaf91d 100644 --- a/src/api/client/redact.rs +++ b/src/api/client/redact.rs @@ -1,5 +1,5 @@ use axum::extract::State; -use conduwuit::{Result, matrix::pdu::PduBuilder}; +use conduwuit::{Err, Result, matrix::pdu::PduBuilder}; use ruma::{ api::client::redact::redact_event, events::room::redaction::RoomRedactionEventContent, }; @@ -17,6 +17,10 @@ pub(crate) async fn redact_event_route( ) -> Result { let sender_user = body.sender_user.as_ref().expect("user is authenticated"); let body = body.body; + if services.users.is_suspended(sender_user).await? { + // TODO: Users can redact their own messages while suspended + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } let state_lock = services.rooms.state.mutex.lock(&body.room_id).await; From a6ba9e3045e7cdaccbae006d0037b045c1348016 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 20:39:24 +0100 Subject: [PATCH 04/23] feat: Prevent suspended users changing their profile --- src/api/client/profile.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/api/client/profile.rs b/src/api/client/profile.rs index e2d1c934..bdba4078 100644 --- a/src/api/client/profile.rs +++ b/src/api/client/profile.rs @@ -36,6 +36,9 @@ pub(crate) async fn set_displayname_route( body: Ruma, ) -> Result { let sender_user = body.sender_user.as_ref().expect("user is authenticated"); + if services.users.is_suspended(sender_user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } if *sender_user != body.user_id && body.appservice_info.is_none() { return Err!(Request(Forbidden("You cannot update the profile of another user"))); @@ -125,6 +128,9 @@ pub(crate) async fn set_avatar_url_route( body: Ruma, ) -> Result { let sender_user = body.sender_user.as_ref().expect("user is authenticated"); + if services.users.is_suspended(sender_user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } if *sender_user != body.user_id && body.appservice_info.is_none() { return Err!(Request(Forbidden("You cannot update the profile of another user"))); From a94128e6986f7f05909329de694e547543b4dc36 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 20:39:57 +0100 Subject: [PATCH 05/23] feat: Prevent suspended users joining/knocking on rooms --- src/api/client/membership.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/api/client/membership.rs b/src/api/client/membership.rs index 145b3cde..d78ebdec 100644 --- a/src/api/client/membership.rs +++ b/src/api/client/membership.rs @@ -178,6 +178,9 @@ pub(crate) async fn join_room_by_id_route( body: Ruma, ) -> Result { let sender_user = body.sender_user(); + if services.users.is_suspended(sender_user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } banned_room_check( &services, @@ -249,6 +252,9 @@ pub(crate) async fn join_room_by_id_or_alias_route( let sender_user = body.sender_user.as_deref().expect("user is authenticated"); let appservice_info = &body.appservice_info; let body = body.body; + if services.users.is_suspended(sender_user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } let (servers, room_id) = match OwnedRoomId::try_from(body.room_id_or_alias) { | Ok(room_id) => { @@ -369,6 +375,9 @@ pub(crate) async fn knock_room_route( ) -> Result { let sender_user = body.sender_user(); let body = &body.body; + if services.users.is_suspended(sender_user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } let (servers, room_id) = match OwnedRoomId::try_from(body.room_id_or_alias.clone()) { | Ok(room_id) => { @@ -492,6 +501,9 @@ pub(crate) async fn invite_user_route( body: Ruma, ) -> Result { let sender_user = body.sender_user(); + if services.users.is_suspended(sender_user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } if !services.users.is_admin(sender_user).await && services.config.block_non_admin_invites { debug_error!( From e127c4e5a2e08498c61df30b8424a01f30671bca Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 20:46:22 +0100 Subject: [PATCH 06/23] feat: Add un/suspend admin commands --- src/admin/user/commands.rs | 34 ++++++++++++++++++++++++++++++++++ src/admin/user/mod.rs | 22 ++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/admin/user/commands.rs b/src/admin/user/commands.rs index e5e481e5..297e4bb3 100644 --- a/src/admin/user/commands.rs +++ b/src/admin/user/commands.rs @@ -224,6 +224,40 @@ pub(super) async fn deactivate(&self, no_leave_rooms: bool, user_id: String) -> .await } +#[admin_command] +pub(super) async fn suspend(&self, user_id: String) -> Result { + let user_id = parse_local_user_id(self.services, &user_id)?; + + if user_id == self.services.globals.server_user { + return Err!("Not allowed to suspend the server service account.",); + } + + if !self.services.users.exists(&user_id).await { + return Err!("User {user_id} does not exist."); + } + self.services.users.suspend_account(&user_id).await; + + self.write_str(&format!("User {user_id} has been suspended.")) + .await +} + +#[admin_command] +pub(super) async fn unsuspend(&self, user_id: String) -> Result { + let user_id = parse_local_user_id(self.services, &user_id)?; + + if user_id == self.services.globals.server_user { + return Err!("Not allowed to unsuspend the server service account.",); + } + + if !self.services.users.exists(&user_id).await { + return Err!("User {user_id} does not exist."); + } + self.services.users.unsuspend_account(&user_id).await; + + self.write_str(&format!("User {user_id} has been unsuspended.")) + .await +} + #[admin_command] pub(super) async fn reset_password(&self, username: String, password: Option) -> Result { let user_id = parse_local_user_id(self.services, &username)?; diff --git a/src/admin/user/mod.rs b/src/admin/user/mod.rs index e789376a..645d3637 100644 --- a/src/admin/user/mod.rs +++ b/src/admin/user/mod.rs @@ -59,6 +59,28 @@ pub(super) enum UserCommand { force: bool, }, + /// - Suspend a user + /// + /// Suspended users are able to log in, sync, and read messages, but are not + /// able to send events nor redact them, cannot change their profile, and + /// are unable to join, invite to, or knock on rooms. + /// + /// Suspended users can still leave rooms and deactivate their account. + /// Suspending them effectively makes them read-only. + Suspend { + /// Username of the user to suspend + user_id: String, + }, + + /// - Unsuspend a user + /// + /// Reverses the effects of the `suspend` command, allowing the user to send + /// messages, change their profile, create room invites, etc. + Unsuspend { + /// Username of the user to unsuspend + user_id: String, + }, + /// - List local users in the database #[clap(alias = "list")] ListUsers, From 5d5350a9fee3b4959ef66b0d18a588d2f2be7dde Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 20:47:02 +0100 Subject: [PATCH 07/23] feat: Prevent suspended users creating new rooms --- src/api/client/room/create.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/api/client/room/create.rs b/src/api/client/room/create.rs index be3fd23b..d1dffc51 100644 --- a/src/api/client/room/create.rs +++ b/src/api/client/room/create.rs @@ -70,6 +70,10 @@ pub(crate) async fn create_room_route( )); } + if services.users.is_suspended(sender_user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } + let room_id: OwnedRoomId = match &body.room_id { | Some(custom_room_id) => custom_room_id_check(&services, custom_room_id)?, | _ => RoomId::new(&services.server.name), From 968c0e236c37d204d9718689dd79888cf32f54e5 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 21:19:10 +0100 Subject: [PATCH 08/23] fix: Create the column appropriately --- src/database/maps.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/database/maps.rs b/src/database/maps.rs index 19f9ced4..91ba2ebe 100644 --- a/src/database/maps.rs +++ b/src/database/maps.rs @@ -378,6 +378,10 @@ pub(super) static MAPS: &[Descriptor] = &[ name: "userid_password", ..descriptor::RANDOM }, + Descriptor { + name: "userid_suspended", + ..descriptor::RANDOM_SMALL + }, Descriptor { name: "userid_presenceid", ..descriptor::RANDOM_SMALL From 8791a9b851f8fc3cd827fe898406561fc4104287 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 21:19:37 +0100 Subject: [PATCH 09/23] fix: Inappropriate empty check I once again, assumed `true` is actually `false`. --- src/service/users/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/service/users/mod.rs b/src/service/users/mod.rs index 1a9f6600..6d3b662f 100644 --- a/src/service/users/mod.rs +++ b/src/service/users/mod.rs @@ -177,7 +177,7 @@ impl Service { self.db .userid_suspended .get(user_id) - .map_ok(|val| val.is_empty()) + .map_ok(|val| !val.is_empty()) .map_err(|_| err!(Request(NotFound("User does not exist.")))) .await } From cc864dc8bbb299b7a48c6fb963a61b6979ceb278 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 21:20:56 +0100 Subject: [PATCH 10/23] feat: Do not allow suspending admin users --- src/admin/user/commands.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/admin/user/commands.rs b/src/admin/user/commands.rs index 297e4bb3..61f10a86 100644 --- a/src/admin/user/commands.rs +++ b/src/admin/user/commands.rs @@ -235,6 +235,9 @@ pub(super) async fn suspend(&self, user_id: String) -> Result { if !self.services.users.exists(&user_id).await { return Err!("User {user_id} does not exist."); } + if self.services.users.is_admin(&user_id).await { + return Err!("Admin users cannot be suspended."); + } self.services.users.suspend_account(&user_id).await; self.write_str(&format!("User {user_id} has been suspended.")) From 1ff8af8e9e6ef723287dd9b174dc69844bf8631c Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 21:24:20 +0100 Subject: [PATCH 11/23] style: Remove unneeded statements (clippy) --- src/service/users/mod.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/service/users/mod.rs b/src/service/users/mod.rs index 6d3b662f..f6a98858 100644 --- a/src/service/users/mod.rs +++ b/src/service/users/mod.rs @@ -15,7 +15,6 @@ use ruma::{ AnyToDeviceEvent, GlobalAccountDataEventType, ignored_user_list::IgnoredUserListEvent, }, serde::Raw, - uint, }; use serde_json::json; @@ -147,12 +146,12 @@ impl Service { } /// Suspend account, placing it in a read-only state - pub async fn suspend_account(&self, user_id: &UserId) -> () { + pub async fn suspend_account(&self, user_id: &UserId) { self.db.userid_suspended.insert(user_id, "1"); } /// Unsuspend account, placing it in a read-write state - pub async fn unsuspend_account(&self, user_id: &UserId) -> () { + pub async fn unsuspend_account(&self, user_id: &UserId) { self.db.userid_suspended.remove(user_id); } From d0548ec0644ebb0e3538f65088db9b16ce0eb5a8 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 21:30:07 +0100 Subject: [PATCH 12/23] feat: Forbid suspended users from sending state events --- src/api/client/state.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/api/client/state.rs b/src/api/client/state.rs index 2ddc8f14..07802b1b 100644 --- a/src/api/client/state.rs +++ b/src/api/client/state.rs @@ -33,6 +33,10 @@ pub(crate) async fn send_state_event_for_key_route( ) -> Result { let sender_user = body.sender_user(); + if services.users.is_suspended(sender_user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } + Ok(send_state_event::v3::Response { event_id: send_state_event_for_key_helper( &services, From 90180916eb13cdcf4e81c668fa6255222c781f1d Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 22:42:31 +0100 Subject: [PATCH 13/23] feat: Prevent suspended users performing room changes Prevents kicks, bans, unbans, and alias modification --- src/api/client/alias.rs | 6 ++++++ src/api/client/directory.rs | 3 +++ src/api/client/membership.rs | 16 ++++++++++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/api/client/alias.rs b/src/api/client/alias.rs index 9f1b05f8..dc7aad44 100644 --- a/src/api/client/alias.rs +++ b/src/api/client/alias.rs @@ -18,6 +18,9 @@ pub(crate) async fn create_alias_route( body: Ruma, ) -> Result { let sender_user = body.sender_user.as_ref().expect("user is authenticated"); + if services.users.is_suspended(sender_user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } services .rooms @@ -63,6 +66,9 @@ pub(crate) async fn delete_alias_route( body: Ruma, ) -> Result { let sender_user = body.sender_user.as_ref().expect("user is authenticated"); + if services.users.is_suspended(sender_user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } services .rooms diff --git a/src/api/client/directory.rs b/src/api/client/directory.rs index aa6ae168..2e219fd9 100644 --- a/src/api/client/directory.rs +++ b/src/api/client/directory.rs @@ -128,6 +128,9 @@ pub(crate) async fn set_room_visibility_route( // Return 404 if the room doesn't exist return Err!(Request(NotFound("Room not found"))); } + if services.users.is_suspended(sender_user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } if services .users diff --git a/src/api/client/membership.rs b/src/api/client/membership.rs index d78ebdec..e6392533 100644 --- a/src/api/client/membership.rs +++ b/src/api/client/membership.rs @@ -578,6 +578,10 @@ pub(crate) async fn kick_user_route( State(services): State, body: Ruma, ) -> Result { + let sender_user = body.sender_user(); + if services.users.is_suspended(sender_user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } let state_lock = services.rooms.state.mutex.lock(&body.room_id).await; let Ok(event) = services @@ -613,7 +617,7 @@ pub(crate) async fn kick_user_route( third_party_invite: None, ..event }), - body.sender_user(), + sender_user, &body.room_id, &state_lock, ) @@ -637,6 +641,10 @@ pub(crate) async fn ban_user_route( return Err!(Request(Forbidden("You cannot ban yourself."))); } + if services.users.is_suspended(sender_user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } + let state_lock = services.rooms.state.mutex.lock(&body.room_id).await; let current_member_content = services @@ -679,6 +687,10 @@ pub(crate) async fn unban_user_route( State(services): State, body: Ruma, ) -> Result { + let sender_user = body.sender_user(); + if services.users.is_suspended(sender_user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } let state_lock = services.rooms.state.mutex.lock(&body.room_id).await; let current_member_content = services @@ -707,7 +719,7 @@ pub(crate) async fn unban_user_route( is_direct: None, ..current_member_content }), - body.sender_user(), + sender_user, &body.room_id, &state_lock, ) From 8e06571e7c7e88c14580c7e6206aecdb12a5eb44 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 22:42:49 +0100 Subject: [PATCH 14/23] feat: Prevent suspended users uploading media --- src/api/client/media.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/api/client/media.rs b/src/api/client/media.rs index 94572413..11d5450c 100644 --- a/src/api/client/media.rs +++ b/src/api/client/media.rs @@ -52,6 +52,9 @@ pub(crate) async fn create_content_route( body: Ruma, ) -> Result { let user = body.sender_user.as_ref().expect("user is authenticated"); + if services.users.is_suspended(user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } let filename = body.filename.as_deref(); let content_type = body.content_type.as_deref(); From 08527a28804f673817793af86ea94086fcba3a5c Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 22:43:35 +0100 Subject: [PATCH 15/23] feat: Prevent suspended users upgrading rooms --- src/api/client/room/upgrade.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/api/client/room/upgrade.rs b/src/api/client/room/upgrade.rs index da5b49fe..d8f5ea83 100644 --- a/src/api/client/room/upgrade.rs +++ b/src/api/client/room/upgrade.rs @@ -2,7 +2,7 @@ use std::cmp::max; use axum::extract::State; use conduwuit::{ - Error, Result, err, info, + Err, Error, Result, err, info, matrix::{StateKey, pdu::PduBuilder}, }; use futures::StreamExt; @@ -63,6 +63,10 @@ pub(crate) async fn upgrade_room_route( )); } + if services.users.is_suspended(sender_user).await? { + return Err!(Request(UserSuspended("You cannot perform this action while suspended."))); + } + // Create a replacement room let replacement_room = RoomId::new(services.globals.server_name()); From 1124097bd17eea3d10b35b9f05539321050b248a Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 22:52:20 +0100 Subject: [PATCH 16/23] feat: Only allow private read receipts when suspended --- src/api/client/read_marker.rs | 49 +++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/src/api/client/read_marker.rs b/src/api/client/read_marker.rs index fbfc8fea..e152869c 100644 --- a/src/api/client/read_marker.rs +++ b/src/api/client/read_marker.rs @@ -58,29 +58,34 @@ pub(crate) async fn set_read_marker_route( } if let Some(event) = &body.read_receipt { - let receipt_content = BTreeMap::from_iter([( - event.to_owned(), - BTreeMap::from_iter([( - ReceiptType::Read, - BTreeMap::from_iter([(sender_user.to_owned(), ruma::events::receipt::Receipt { - ts: Some(MilliSecondsSinceUnixEpoch::now()), - thread: ReceiptThread::Unthreaded, - })]), - )]), - )]); + if !services.users.is_suspended(sender_user).await? { + let receipt_content = BTreeMap::from_iter([( + event.to_owned(), + BTreeMap::from_iter([( + ReceiptType::Read, + BTreeMap::from_iter([( + sender_user.to_owned(), + ruma::events::receipt::Receipt { + ts: Some(MilliSecondsSinceUnixEpoch::now()), + thread: ReceiptThread::Unthreaded, + }, + )]), + )]), + )]); - services - .rooms - .read_receipt - .readreceipt_update( - sender_user, - &body.room_id, - &ruma::events::receipt::ReceiptEvent { - content: ruma::events::receipt::ReceiptEventContent(receipt_content), - room_id: body.room_id.clone(), - }, - ) - .await; + services + .rooms + .read_receipt + .readreceipt_update( + sender_user, + &body.room_id, + &ruma::events::receipt::ReceiptEvent { + content: ruma::events::receipt::ReceiptEventContent(receipt_content), + room_id: body.room_id.clone(), + }, + ) + .await; + } } if let Some(event) = &body.private_read_receipt { From 72f8cb30384a32b1489b676fcfc76fc59f323c42 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sat, 28 Jun 2025 22:53:25 +0100 Subject: [PATCH 17/23] feat: Do not allow suspended users to send typing statuses --- src/api/client/typing.rs | 67 ++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/src/api/client/typing.rs b/src/api/client/typing.rs index 1d8d02fd..7b0df538 100644 --- a/src/api/client/typing.rs +++ b/src/api/client/typing.rs @@ -26,41 +26,42 @@ pub(crate) async fn create_typing_event_route( { return Err!(Request(Forbidden("You are not in this room."))); } - - match body.state { - | Typing::Yes(duration) => { - let duration = utils::clamp( - duration.as_millis().try_into().unwrap_or(u64::MAX), + if !services.users.is_suspended(sender_user).await? { + match body.state { + | Typing::Yes(duration) => { + let duration = utils::clamp( + duration.as_millis().try_into().unwrap_or(u64::MAX), + services + .server + .config + .typing_client_timeout_min_s + .try_mul(1000)?, + services + .server + .config + .typing_client_timeout_max_s + .try_mul(1000)?, + ); services - .server - .config - .typing_client_timeout_min_s - .try_mul(1000)?, + .rooms + .typing + .typing_add( + sender_user, + &body.room_id, + utils::millis_since_unix_epoch() + .checked_add(duration) + .expect("user typing timeout should not get this high"), + ) + .await?; + }, + | _ => { services - .server - .config - .typing_client_timeout_max_s - .try_mul(1000)?, - ); - services - .rooms - .typing - .typing_add( - sender_user, - &body.room_id, - utils::millis_since_unix_epoch() - .checked_add(duration) - .expect("user typing timeout should not get this high"), - ) - .await?; - }, - | _ => { - services - .rooms - .typing - .typing_remove(sender_user, &body.room_id) - .await?; - }, + .rooms + .typing + .typing_remove(sender_user, &body.room_id) + .await?; + }, + } } // ping presence From eb2e3b3bb70af18ad0821d046fe51926977b5d0a Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sun, 29 Jun 2025 01:52:02 +0100 Subject: [PATCH 18/23] fix: Missing suspensions shouldn't error Turns out copying and pasting the function above verbatim actually introduces more problems than it solves! --- src/service/users/mod.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/service/users/mod.rs b/src/service/users/mod.rs index f6a98858..d80a7e22 100644 --- a/src/service/users/mod.rs +++ b/src/service/users/mod.rs @@ -2,7 +2,7 @@ use std::{collections::BTreeMap, mem, sync::Arc}; use conduwuit::{ Err, Error, Result, Server, at, debug_warn, err, trace, - utils::{self, ReadyExt, stream::TryIgnore, string::Unquoted}, + utils::{self, ReadyExt, TryFutureExtExt, stream::TryIgnore, string::Unquoted}, }; use database::{Deserialized, Ignore, Interfix, Json, Map}; use futures::{Stream, StreamExt, TryFutureExt}; @@ -176,8 +176,7 @@ impl Service { self.db .userid_suspended .get(user_id) - .map_ok(|val| !val.is_empty()) - .map_err(|_| err!(Request(NotFound("User does not exist.")))) + .map_ok_or(Ok(false), |_| Ok(true)) .await } From d8a27eeb54b79a1126dd08c48f8f20d9cc0c0104 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sun, 29 Jun 2025 02:28:04 +0100 Subject: [PATCH 19/23] fix: Failing open on database errors oops --- src/service/users/mod.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/service/users/mod.rs b/src/service/users/mod.rs index d80a7e22..5db7dc1d 100644 --- a/src/service/users/mod.rs +++ b/src/service/users/mod.rs @@ -1,11 +1,11 @@ use std::{collections::BTreeMap, mem, sync::Arc}; use conduwuit::{ - Err, Error, Result, Server, at, debug_warn, err, trace, + Err, Error, Result, Server, at, debug_warn, err, result, trace, utils::{self, ReadyExt, TryFutureExtExt, stream::TryIgnore, string::Unquoted}, }; use database::{Deserialized, Ignore, Interfix, Json, Map}; -use futures::{Stream, StreamExt, TryFutureExt}; +use futures::{Stream, StreamExt, TryFutureExt, TryStreamExt}; use ruma::{ DeviceId, KeyId, MilliSecondsSinceUnixEpoch, OneTimeKeyAlgorithm, OneTimeKeyId, OneTimeKeyName, OwnedDeviceId, OwnedKeyId, OwnedMxcUri, OwnedUserId, RoomId, UInt, UserId, @@ -176,7 +176,19 @@ impl Service { self.db .userid_suspended .get(user_id) - .map_ok_or(Ok(false), |_| Ok(true)) + .map_ok_or_else( + |err| { + if err.is_not_found() { + Ok(false) + } else { + err!(Database(error!( + "Failed to check if user {user_id} is suspended: {err}" + ))); + Ok(true) + } + }, + |_| Ok(true), + ) .await } From 13e17d52e02f33847bed089d4451d248694be854 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sun, 29 Jun 2025 02:30:52 +0100 Subject: [PATCH 20/23] style: Remove unnecessary imports (clippy) --- src/service/users/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/service/users/mod.rs b/src/service/users/mod.rs index 5db7dc1d..b2a42959 100644 --- a/src/service/users/mod.rs +++ b/src/service/users/mod.rs @@ -1,11 +1,11 @@ use std::{collections::BTreeMap, mem, sync::Arc}; use conduwuit::{ - Err, Error, Result, Server, at, debug_warn, err, result, trace, - utils::{self, ReadyExt, TryFutureExtExt, stream::TryIgnore, string::Unquoted}, + Err, Error, Result, Server, at, debug_warn, err, trace, + utils::{self, ReadyExt, stream::TryIgnore, string::Unquoted}, }; use database::{Deserialized, Ignore, Interfix, Json, Map}; -use futures::{Stream, StreamExt, TryFutureExt, TryStreamExt}; +use futures::{Stream, StreamExt, TryFutureExt}; use ruma::{ DeviceId, KeyId, MilliSecondsSinceUnixEpoch, OneTimeKeyAlgorithm, OneTimeKeyId, OneTimeKeyName, OwnedDeviceId, OwnedKeyId, OwnedMxcUri, OwnedUserId, RoomId, UInt, UserId, From ecc6fda98b135e9e5fdb9cd93124baac7a4fc001 Mon Sep 17 00:00:00 2001 From: Jade Ellis Date: Sun, 29 Jun 2025 15:07:04 +0100 Subject: [PATCH 21/23] feat: Record metadata about user suspensions --- src/admin/user/commands.rs | 6 +++- src/database/maps.rs | 2 +- src/service/users/mod.rs | 56 ++++++++++++++++++++++++-------------- 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/admin/user/commands.rs b/src/admin/user/commands.rs index 61f10a86..ad2d1c78 100644 --- a/src/admin/user/commands.rs +++ b/src/admin/user/commands.rs @@ -238,7 +238,11 @@ pub(super) async fn suspend(&self, user_id: String) -> Result { if self.services.users.is_admin(&user_id).await { return Err!("Admin users cannot be suspended."); } - self.services.users.suspend_account(&user_id).await; + // TODO: Record the actual user that sent the suspension where possible + self.services + .users + .suspend_account(&user_id, self.services.globals.server_user.as_ref()) + .await; self.write_str(&format!("User {user_id} has been suspended.")) .await diff --git a/src/database/maps.rs b/src/database/maps.rs index 91ba2ebe..214dbf34 100644 --- a/src/database/maps.rs +++ b/src/database/maps.rs @@ -379,7 +379,7 @@ pub(super) static MAPS: &[Descriptor] = &[ ..descriptor::RANDOM }, Descriptor { - name: "userid_suspended", + name: "userid_suspension", ..descriptor::RANDOM_SMALL }, Descriptor { diff --git a/src/service/users/mod.rs b/src/service/users/mod.rs index b2a42959..d2dfccd9 100644 --- a/src/service/users/mod.rs +++ b/src/service/users/mod.rs @@ -16,10 +16,21 @@ use ruma::{ }, serde::Raw, }; +use serde::{Deserialize, Serialize}; use serde_json::json; use crate::{Dep, account_data, admin, globals, rooms}; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserSuspension { + /// Whether the user is currently suspended + pub suspended: bool, + /// When the user was suspended (Unix timestamp in milliseconds) + pub suspended_at: u64, + /// User ID of who suspended this user + pub suspended_by: String, +} + pub struct Service { services: Services, db: Data, @@ -52,7 +63,7 @@ struct Data { userid_lastonetimekeyupdate: Arc, userid_masterkeyid: Arc, userid_password: Arc, - userid_suspended: Arc, + userid_suspension: Arc, userid_selfsigningkeyid: Arc, userid_usersigningkeyid: Arc, useridprofilekey_value: Arc, @@ -88,7 +99,7 @@ impl crate::Service for Service { userid_lastonetimekeyupdate: args.db["userid_lastonetimekeyupdate"].clone(), userid_masterkeyid: args.db["userid_masterkeyid"].clone(), userid_password: args.db["userid_password"].clone(), - userid_suspended: args.db["userid_suspended"].clone(), + userid_suspension: args.db["userid_suspension"].clone(), userid_selfsigningkeyid: args.db["userid_selfsigningkeyid"].clone(), userid_usersigningkeyid: args.db["userid_usersigningkeyid"].clone(), useridprofilekey_value: args.db["useridprofilekey_value"].clone(), @@ -146,13 +157,20 @@ impl Service { } /// Suspend account, placing it in a read-only state - pub async fn suspend_account(&self, user_id: &UserId) { - self.db.userid_suspended.insert(user_id, "1"); + pub async fn suspend_account(&self, user_id: &UserId, suspending_user: &UserId) { + self.db.userid_suspension.raw_put( + user_id, + Json(UserSuspension { + suspended: true, + suspended_at: MilliSecondsSinceUnixEpoch::now().get().into(), + suspended_by: suspending_user.to_string(), + }), + ); } /// Unsuspend account, placing it in a read-write state pub async fn unsuspend_account(&self, user_id: &UserId) { - self.db.userid_suspended.remove(user_id); + self.db.userid_suspension.remove(user_id); } /// Check if a user has an account on this homeserver. @@ -173,23 +191,21 @@ impl Service { /// Check if account is suspended pub async fn is_suspended(&self, user_id: &UserId) -> Result { - self.db - .userid_suspended + match self + .db + .userid_suspension .get(user_id) - .map_ok_or_else( - |err| { - if err.is_not_found() { - Ok(false) - } else { - err!(Database(error!( - "Failed to check if user {user_id} is suspended: {err}" - ))); - Ok(true) - } - }, - |_| Ok(true), - ) .await + .deserialized::() + { + | Ok(s) => Ok(s.suspended), + | Err(e) => + if e.is_not_found() { + Ok(false) + } else { + Err(e) + }, + } } /// Check if account is active, infallible From acb74faa070801c4ee48cc76f6d3dd19174876b9 Mon Sep 17 00:00:00 2001 From: Jade Ellis Date: Sun, 29 Jun 2025 15:17:27 +0100 Subject: [PATCH 22/23] feat: Pass sender through admin commands --- src/admin/context.rs | 15 ++++++++++++++- src/admin/processor.rs | 1 + src/admin/user/commands.rs | 2 +- src/service/admin/mod.rs | 23 ++++++++++++++++++++--- src/service/rooms/timeline/mod.rs | 8 +++++--- 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/admin/context.rs b/src/admin/context.rs index 270537be..b453d88f 100644 --- a/src/admin/context.rs +++ b/src/admin/context.rs @@ -7,13 +7,14 @@ use futures::{ io::{AsyncWriteExt, BufWriter}, lock::Mutex, }; -use ruma::EventId; +use ruma::{EventId, UserId}; pub(crate) struct Context<'a> { pub(crate) services: &'a Services, pub(crate) body: &'a [&'a str], pub(crate) timer: SystemTime, pub(crate) reply_id: Option<&'a EventId>, + pub(crate) sender: Option<&'a UserId>, pub(crate) output: Mutex>>, } @@ -36,4 +37,16 @@ impl Context<'_> { output.write_all(s.as_bytes()).map_err(Into::into).await }) } + + /// Get the sender of the admin command, if available + pub(crate) fn sender(&self) -> Option<&UserId> { self.sender } + + /// Check if the command has sender information + pub(crate) fn has_sender(&self) -> bool { self.sender.is_some() } + + /// Get the sender as a string, or service user ID if not available + pub(crate) fn sender_or_service_user(&self) -> &UserId { + self.sender + .unwrap_or_else(|| self.services.globals.server_user.as_ref()) + } } diff --git a/src/admin/processor.rs b/src/admin/processor.rs index f7b7140f..8d1fe89c 100644 --- a/src/admin/processor.rs +++ b/src/admin/processor.rs @@ -63,6 +63,7 @@ async fn process_command(services: Arc, input: &CommandInput) -> Proce body: &body, timer: SystemTime::now(), reply_id: input.reply_id.as_deref(), + sender: input.sender.as_deref(), output: BufWriter::new(Vec::new()).into(), }; diff --git a/src/admin/user/commands.rs b/src/admin/user/commands.rs index ad2d1c78..d094fc5f 100644 --- a/src/admin/user/commands.rs +++ b/src/admin/user/commands.rs @@ -241,7 +241,7 @@ pub(super) async fn suspend(&self, user_id: String) -> Result { // TODO: Record the actual user that sent the suspension where possible self.services .users - .suspend_account(&user_id, self.services.globals.server_user.as_ref()) + .suspend_account(&user_id, self.sender_or_service_user()) .await; self.write_str(&format!("User {user_id} has been suspended.")) diff --git a/src/service/admin/mod.rs b/src/service/admin/mod.rs index 683f5400..86e12c3c 100644 --- a/src/service/admin/mod.rs +++ b/src/service/admin/mod.rs @@ -45,11 +45,13 @@ struct Services { services: StdRwLock>>, } -/// Inputs to a command are a multi-line string and optional reply_id. +/// Inputs to a command are a multi-line string, optional reply_id, and optional +/// sender. #[derive(Debug)] pub struct CommandInput { pub command: String, pub reply_id: Option, + pub sender: Option>, } /// Prototype of the tab-completer. The input is buffered text when tab @@ -162,7 +164,22 @@ impl Service { pub fn command(&self, command: String, reply_id: Option) -> Result<()> { self.channel .0 - .send(CommandInput { command, reply_id }) + .send(CommandInput { command, reply_id, sender: None }) + .map_err(|e| err!("Failed to enqueue admin command: {e:?}")) + } + + /// Posts a command to the command processor queue with sender information + /// and returns. Processing will take place on the service worker's task + /// asynchronously. Errors if the queue is full. + pub fn command_with_sender( + &self, + command: String, + reply_id: Option, + sender: Box, + ) -> Result<()> { + self.channel + .0 + .send(CommandInput { command, reply_id, sender: Some(sender) }) .map_err(|e| err!("Failed to enqueue admin command: {e:?}")) } @@ -173,7 +190,7 @@ impl Service { command: String, reply_id: Option, ) -> ProcessorResult { - self.process_command(CommandInput { command, reply_id }) + self.process_command(CommandInput { command, reply_id, sender: None }) .await } diff --git a/src/service/rooms/timeline/mod.rs b/src/service/rooms/timeline/mod.rs index 37963246..534d8faf 100644 --- a/src/service/rooms/timeline/mod.rs +++ b/src/service/rooms/timeline/mod.rs @@ -536,9 +536,11 @@ impl Service { self.services.search.index_pdu(shortroomid, &pdu_id, &body); if self.services.admin.is_admin_command(pdu, &body).await { - self.services - .admin - .command(body, Some((*pdu.event_id).into()))?; + self.services.admin.command_with_sender( + body, + Some((*pdu.event_id).into()), + pdu.sender.clone().into(), + )?; } } }, From d4862b8ead02a6ef61580ddefb24febb56c108b4 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sun, 29 Jun 2025 16:26:04 +0100 Subject: [PATCH 23/23] style: Remove redundant, unused functions --- src/admin/context.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/admin/context.rs b/src/admin/context.rs index b453d88f..3d3cffb7 100644 --- a/src/admin/context.rs +++ b/src/admin/context.rs @@ -38,12 +38,6 @@ impl Context<'_> { }) } - /// Get the sender of the admin command, if available - pub(crate) fn sender(&self) -> Option<&UserId> { self.sender } - - /// Check if the command has sender information - pub(crate) fn has_sender(&self) -> bool { self.sender.is_some() } - /// Get the sender as a string, or service user ID if not available pub(crate) fn sender_or_service_user(&self) -> &UserId { self.sender