From f07f0824ac508658afaf50778e03143651142517 Mon Sep 17 00:00:00 2001 From: Jason Volk Date: Fri, 22 Mar 2024 19:37:36 -0700 Subject: [PATCH 1/2] add file listing to database abstraction. Signed-off-by: Jason Volk --- src/database/abstraction.rs | 2 ++ src/database/abstraction/rocksdb.rs | 24 ++++++++++++++++++++++++ src/database/key_value/globals.rs | 2 ++ src/service/globals/data.rs | 1 + 4 files changed, 29 insertions(+) diff --git a/src/database/abstraction.rs b/src/database/abstraction.rs index 45e62b6ba..aa4950380 100644 --- a/src/database/abstraction.rs +++ b/src/database/abstraction.rs @@ -34,6 +34,8 @@ pub(crate) trait KeyValueDatabaseEngine: Send + Sync { fn backup(&self) -> Result<(), Box> { unimplemented!() } fn backup_list(&self) -> Result { Ok(String::new()) } + + fn file_list(&self) -> Result { Ok(String::new()) } } pub(crate) trait KvTree: Send + Sync { diff --git a/src/database/abstraction/rocksdb.rs b/src/database/abstraction/rocksdb.rs index 597552dfc..0c9b0e54b 100644 --- a/src/database/abstraction/rocksdb.rs +++ b/src/database/abstraction/rocksdb.rs @@ -304,6 +304,30 @@ impl KeyValueDatabaseEngine for Arc { Ok(res) } + fn file_list(&self) -> Result { + match self.rocks.live_files() { + Err(e) => Ok(String::from(e)), + Ok(files) => { + let mut res = String::new(); + for file in files { + let _ = std::fmt::write( + &mut res, + format_args!( + "L{} {:<13} {:7}+ {:4}- {:9} {}
", + file.level, + file.name, + file.num_entries, + file.num_deletions, + file.size, + file.column_family_name, + ), + ); + } + Ok(res) + }, + } + } + // TODO: figure out if this is needed for rocksdb #[allow(dead_code)] fn clear_caches(&self) {} diff --git a/src/database/key_value/globals.rs b/src/database/key_value/globals.rs index 14aa8d668..dc4988b6c 100644 --- a/src/database/key_value/globals.rs +++ b/src/database/key_value/globals.rs @@ -286,4 +286,6 @@ lasttimelinecount_cache: {lasttimelinecount_cache}\n" fn backup(&self) -> Result<(), Box> { self.db.backup() } fn backup_list(&self) -> Result { self.db.backup_list() } + + fn file_list(&self) -> Result { self.db.file_list() } } diff --git a/src/service/globals/data.rs b/src/service/globals/data.rs index 1615b5a07..51cfc6c87 100644 --- a/src/service/globals/data.rs +++ b/src/service/globals/data.rs @@ -37,4 +37,5 @@ pub trait Data: Send + Sync { fn bump_database_version(&self, new_version: u64) -> Result<()>; fn backup(&self) -> Result<(), Box> { unimplemented!() } fn backup_list(&self) -> Result { Ok(String::new()) } + fn file_list(&self) -> Result { Ok(String::new()) } } From c8d9d8d67359dee05acf3671a9a84d53536c9894 Mon Sep 17 00:00:00 2001 From: Jason Volk Date: Fri, 22 Mar 2024 03:37:55 -0700 Subject: [PATCH 2/2] split admin commands into modules. Signed-off-by: Jason Volk --- src/service/admin/appservice.rs | 92 ++ src/service/admin/debug.rs | 294 ++++ src/service/admin/federation.rs | 110 ++ src/service/admin/media.rs | 213 +++ src/service/admin/mod.rs | 1996 +------------------------- src/service/admin/room.rs | 90 ++ src/service/admin/room_alias.rs | 160 +++ src/service/admin/room_directory.rs | 93 ++ src/service/admin/room_moderation.rs | 480 +++++++ src/service/admin/server.rs | 106 ++ src/service/admin/user.rs | 361 +++++ 11 files changed, 2035 insertions(+), 1960 deletions(-) create mode 100644 src/service/admin/appservice.rs create mode 100644 src/service/admin/debug.rs create mode 100644 src/service/admin/federation.rs create mode 100644 src/service/admin/media.rs create mode 100644 src/service/admin/room.rs create mode 100644 src/service/admin/room_alias.rs create mode 100644 src/service/admin/room_directory.rs create mode 100644 src/service/admin/room_moderation.rs create mode 100644 src/service/admin/server.rs create mode 100644 src/service/admin/user.rs diff --git a/src/service/admin/appservice.rs b/src/service/admin/appservice.rs new file mode 100644 index 000000000..db1cda704 --- /dev/null +++ b/src/service/admin/appservice.rs @@ -0,0 +1,92 @@ +use clap::Subcommand; +use ruma::{api::appservice::Registration, events::room::message::RoomMessageEventContent}; + +use crate::{service::admin::escape_html, services, Result}; + +#[cfg_attr(test, derive(Debug))] +#[derive(Subcommand)] +pub(crate) enum AppserviceCommand { + /// - Register an appservice using its registration YAML + /// + /// This command needs a YAML generated by an appservice (such as a bridge), + /// which must be provided in a Markdown code block below the command. + /// + /// Registering a new bridge using the ID of an existing bridge will replace + /// the old one. + Register, + + /// - Unregister an appservice using its ID + /// + /// You can find the ID using the `list-appservices` command. + Unregister { + /// The appservice to unregister + appservice_identifier: String, + }, + + /// - Show an appservice's config using its ID + /// + /// You can find the ID using the `list-appservices` command. + Show { + /// The appservice to show + appservice_identifier: String, + }, + + /// - List all the currently registered appservices + List, +} + +pub(crate) async fn process(command: AppserviceCommand, body: Vec<&str>) -> Result { + match command { + AppserviceCommand::Register => { + if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" { + let appservice_config = body[1..body.len() - 1].join("\n"); + let parsed_config = serde_yaml::from_str::(&appservice_config); + match parsed_config { + Ok(yaml) => match services().appservice.register_appservice(yaml).await { + Ok(id) => Ok(RoomMessageEventContent::text_plain(format!( + "Appservice registered with ID: {id}." + ))), + Err(e) => Ok(RoomMessageEventContent::text_plain(format!( + "Failed to register appservice: {e}" + ))), + }, + Err(e) => Ok(RoomMessageEventContent::text_plain(format!( + "Could not parse appservice config: {e}" + ))), + } + } else { + Ok(RoomMessageEventContent::text_plain( + "Expected code block in command body. Add --help for details.", + )) + } + }, + AppserviceCommand::Unregister { + appservice_identifier, + } => match services().appservice.unregister_appservice(&appservice_identifier).await { + Ok(()) => Ok(RoomMessageEventContent::text_plain("Appservice unregistered.")), + Err(e) => Ok(RoomMessageEventContent::text_plain(format!( + "Failed to unregister appservice: {e}" + ))), + }, + AppserviceCommand::Show { + appservice_identifier, + } => match services().appservice.get_registration(&appservice_identifier).await { + Some(config) => { + let config_str = serde_yaml::to_string(&config).expect("config should've been validated on register"); + let output = format!("Config for {}:\n\n```yaml\n{}\n```", appservice_identifier, config_str,); + let output_html = format!( + "Config for {}:\n\n
{}
", + escape_html(&appservice_identifier), + escape_html(&config_str), + ); + Ok(RoomMessageEventContent::text_html(output, output_html)) + }, + None => Ok(RoomMessageEventContent::text_plain("Appservice does not exist.")), + }, + AppserviceCommand::List => { + let appservices = services().appservice.iter_ids().await; + let output = format!("Appservices ({}): {}", appservices.len(), appservices.join(", ")); + Ok(RoomMessageEventContent::text_plain(output)) + }, + } +} diff --git a/src/service/admin/debug.rs b/src/service/admin/debug.rs new file mode 100644 index 000000000..bd415ddc3 --- /dev/null +++ b/src/service/admin/debug.rs @@ -0,0 +1,294 @@ +use std::{collections::BTreeMap, sync::Arc, time::Instant}; + +use clap::Subcommand; +use ruma::{ + api::client::error::ErrorKind, events::room::message::RoomMessageEventContent, CanonicalJsonObject, EventId, + RoomId, RoomVersionId, ServerName, +}; +use tokio::sync::RwLock; +use tracing::{debug, error, info, warn}; + +use crate::{api::server_server::parse_incoming_pdu, services, utils::HtmlEscape, Error, PduEvent, Result}; + +#[cfg_attr(test, derive(Debug))] +#[derive(Subcommand)] +pub(crate) enum DebugCommand { + /// - Get the auth_chain of a PDU + GetAuthChain { + /// An event ID (the $ character followed by the base64 reference hash) + event_id: Box, + }, + + /// - Parse and print a PDU from a JSON + /// + /// The PDU event is only checked for validity and is not added to the + /// database. + /// + /// This command needs a JSON blob provided in a Markdown code block below + /// the command. + ParsePdu, + + /// - Retrieve and print a PDU by ID from the conduwuit database + GetPdu { + /// An event ID (a $ followed by the base64 reference hash) + event_id: Box, + }, + + /// - Attempts to retrieve a PDU from a remote server. Inserts it into our + /// database/timeline if found and we do not have this PDU already + /// (following normal event auth rules, handles it as an incoming PDU). + GetRemotePdu { + /// An event ID (a $ followed by the base64 reference hash) + event_id: Box, + + /// Argument for us to attempt to fetch the event from the + /// specified remote server. + server: Box, + }, + + /// - Gets all the room state events for the specified room. + /// + /// This is functionally equivalent to `GET + /// /_matrix/client/v3/rooms/{roomid}/state`, except the admin command does + /// *not* check if the sender user is allowed to see state events. This is + /// done because it's implied that server admins here have database access + /// and can see/get room info themselves anyways if they were malicious + /// admins. + /// + /// Of course the check is still done on the actual client API. + GetRoomState { + /// Room ID + room_id: Box, + }, + + /// - Forces device lists for all local and remote users to be updated (as + /// having new keys available) + ForceDeviceListUpdates, +} + +pub(crate) async fn process(command: DebugCommand, body: Vec<&str>) -> Result { + Ok(match command { + DebugCommand::GetAuthChain { + event_id, + } => { + let event_id = Arc::::from(event_id); + if let Some(event) = services().rooms.timeline.get_pdu_json(&event_id)? { + let room_id_str = event + .get("room_id") + .and_then(|val| val.as_str()) + .ok_or_else(|| Error::bad_database("Invalid event in database"))?; + + let room_id = <&RoomId>::try_from(room_id_str) + .map_err(|_| Error::bad_database("Invalid room id field in event in database"))?; + let start = Instant::now(); + let count = services().rooms.auth_chain.get_auth_chain(room_id, vec![event_id]).await?.count(); + let elapsed = start.elapsed(); + RoomMessageEventContent::text_plain(format!("Loaded auth chain with length {count} in {elapsed:?}")) + } else { + RoomMessageEventContent::text_plain("Event not found.") + } + }, + DebugCommand::ParsePdu => { + if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" { + let string = body[1..body.len() - 1].join("\n"); + match serde_json::from_str(&string) { + Ok(value) => match ruma::signatures::reference_hash(&value, &RoomVersionId::V6) { + Ok(hash) => { + let event_id = EventId::parse(format!("${hash}")); + + match serde_json::from_value::( + serde_json::to_value(value).expect("value is json"), + ) { + Ok(pdu) => { + RoomMessageEventContent::text_plain(format!("EventId: {event_id:?}\n{pdu:#?}")) + }, + Err(e) => RoomMessageEventContent::text_plain(format!( + "EventId: {event_id:?}\nCould not parse event: {e}" + )), + } + }, + Err(e) => RoomMessageEventContent::text_plain(format!("Could not parse PDU JSON: {e:?}")), + }, + Err(e) => RoomMessageEventContent::text_plain(format!("Invalid json in command body: {e}")), + } + } else { + RoomMessageEventContent::text_plain("Expected code block in command body.") + } + }, + DebugCommand::GetPdu { + event_id, + } => { + let mut outlier = false; + let mut pdu_json = services().rooms.timeline.get_non_outlier_pdu_json(&event_id)?; + if pdu_json.is_none() { + outlier = true; + pdu_json = services().rooms.timeline.get_pdu_json(&event_id)?; + } + match pdu_json { + Some(json) => { + let json_text = serde_json::to_string_pretty(&json).expect("canonical json is valid json"); + return Ok(RoomMessageEventContent::text_html( + format!( + "{}\n```json\n{}\n```", + if outlier { + "Outlier PDU found in our database" + } else { + "PDU found in our database" + }, + json_text + ), + format!( + "

{}

\n
{}\n
\n", + if outlier { + "Outlier PDU found in our database" + } else { + "PDU found in our database" + }, + HtmlEscape(&json_text) + ), + )); + }, + None => { + return Ok(RoomMessageEventContent::text_plain("PDU not found locally.")); + }, + } + }, + DebugCommand::GetRemotePdu { + event_id, + server, + } => { + if !services().globals.config.allow_federation { + return Ok(RoomMessageEventContent::text_plain( + "Federation is disabled on this homeserver.", + )); + } + + if server == services().globals.server_name() { + return Ok(RoomMessageEventContent::text_plain( + "Not allowed to send federation requests to ourselves. Please use `get-pdu` for fetching local \ + PDUs.", + )); + } + + // TODO: use Futures as some requests may take a while so we dont block the + // admin room + match services() + .sending + .send_federation_request( + &server, + ruma::api::federation::event::get_event::v1::Request { + event_id: event_id.clone().into(), + }, + ) + .await + { + Ok(response) => { + let json: CanonicalJsonObject = serde_json::from_str(response.pdu.get()).map_err(|e| { + warn!( + "Requested event ID {event_id} from server but failed to convert from RawValue to \ + CanonicalJsonObject (malformed event/response?): {e}" + ); + Error::BadRequest(ErrorKind::Unknown, "Received response from server but failed to parse PDU") + })?; + + debug!("Attempting to parse PDU: {:?}", &response.pdu); + let parsed_pdu = { + let parsed_result = parse_incoming_pdu(&response.pdu); + let (event_id, value, room_id) = match parsed_result { + Ok(t) => t, + Err(e) => { + warn!("Failed to parse PDU: {e}"); + info!("Full PDU: {:?}", &response.pdu); + return Ok(RoomMessageEventContent::text_plain(format!( + "Failed to parse PDU remote server {server} sent us: {e}" + ))); + }, + }; + + vec![(event_id, value, room_id)] + }; + + let pub_key_map = RwLock::new(BTreeMap::new()); + + debug!("Attempting to fetch homeserver signing keys for {server}"); + services() + .rooms + .event_handler + .fetch_required_signing_keys( + parsed_pdu.iter().map(|(_event_id, event, _room_id)| event), + &pub_key_map, + ) + .await + .unwrap_or_else(|e| { + warn!("Could not fetch all signatures for PDUs from {server}: {e:?}"); + }); + + info!("Attempting to handle event ID {event_id} as backfilled PDU"); + services().rooms.timeline.backfill_pdu(&server, response.pdu, &pub_key_map).await?; + + let json_text = serde_json::to_string_pretty(&json).expect("canonical json is valid json"); + + return Ok(RoomMessageEventContent::text_html( + format!( + "{}\n```json\n{}\n```", + "Got PDU from specified server and handled as backfilled PDU successfully. Event body:", + json_text + ), + format!( + "

{}

\n
{}\n
\n", + "Got PDU from specified server and handled as backfilled PDU successfully. Event body:", + HtmlEscape(&json_text) + ), + )); + }, + Err(_) => { + return Ok(RoomMessageEventContent::text_plain( + "Remote server did not have PDU or failed sending request to remote server.", + )); + }, + } + }, + DebugCommand::GetRoomState { + room_id, + } => { + let room_state = services() + .rooms + .state_accessor + .room_state_full(&room_id) + .await? + .values() + .map(|pdu| pdu.to_state_event()) + .collect::>(); + + if room_state.is_empty() { + return Ok(RoomMessageEventContent::text_plain( + "Unable to find room state in our database (vector is empty)", + )); + } + + let json_text = serde_json::to_string_pretty(&room_state).map_err(|e| { + error!("Failed converting room state vector in our database to pretty JSON: {e}"); + Error::bad_database( + "Failed to convert room state events to pretty JSON, possible invalid room state events in our \ + database", + ) + })?; + + return Ok(RoomMessageEventContent::text_html( + format!("{}\n```json\n{}\n```", "Found full room state", json_text), + format!( + "

{}

\n
{}\n
\n", + "Found full room state", + HtmlEscape(&json_text) + ), + )); + }, + DebugCommand::ForceDeviceListUpdates => { + // Force E2EE device list updates for all users + for user_id in services().users.iter().filter_map(std::result::Result::ok) { + services().users.mark_device_key_update(&user_id)?; + } + RoomMessageEventContent::text_plain("Marked all devices for all users as having new keys to update") + }, + }) +} diff --git a/src/service/admin/federation.rs b/src/service/admin/federation.rs new file mode 100644 index 000000000..ed72bc1d9 --- /dev/null +++ b/src/service/admin/federation.rs @@ -0,0 +1,110 @@ +use std::{collections::BTreeMap, fmt::Write as _}; + +use clap::Subcommand; +use ruma::{events::room::message::RoomMessageEventContent, RoomId}; +use tokio::sync::RwLock; + +use crate::{services, Result}; + +#[cfg_attr(test, derive(Debug))] +#[derive(Subcommand)] +pub(crate) enum FederationCommand { + /// - List all rooms we are currently handling an incoming pdu from + IncomingFederation, + + /// - Disables incoming federation handling for a room. + DisableRoom { + room_id: Box, + }, + + /// - Enables incoming federation handling for a room again. + EnableRoom { + room_id: Box, + }, + + /// - Verify json signatures + /// + /// This command needs a JSON blob provided in a Markdown code block below + /// the command. + SignJson, + + /// - Verify json signatures + /// + /// This command needs a JSON blob provided in a Markdown code block below + /// the command. + VerifyJson, +} + +pub(crate) async fn process(command: FederationCommand, body: Vec<&str>) -> Result { + match command { + FederationCommand::DisableRoom { + room_id, + } => { + services().rooms.metadata.disable_room(&room_id, true)?; + Ok(RoomMessageEventContent::text_plain("Room disabled.")) + }, + FederationCommand::EnableRoom { + room_id, + } => { + services().rooms.metadata.disable_room(&room_id, false)?; + Ok(RoomMessageEventContent::text_plain("Room enabled.")) + }, + FederationCommand::IncomingFederation => { + let map = services().globals.roomid_federationhandletime.read().await; + let mut msg = format!("Handling {} incoming pdus:\n", map.len()); + + for (r, (e, i)) in map.iter() { + let elapsed = i.elapsed(); + let _ = writeln!(msg, "{} {}: {}m{}s", r, e, elapsed.as_secs() / 60, elapsed.as_secs() % 60); + } + Ok(RoomMessageEventContent::text_plain(&msg)) + }, + FederationCommand::SignJson => { + if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" { + let string = body[1..body.len() - 1].join("\n"); + match serde_json::from_str(&string) { + Ok(mut value) => { + ruma::signatures::sign_json( + services().globals.server_name().as_str(), + services().globals.keypair(), + &mut value, + ) + .expect("our request json is what ruma expects"); + let json_text = serde_json::to_string_pretty(&value).expect("canonical json is valid json"); + Ok(RoomMessageEventContent::text_plain(json_text)) + }, + Err(e) => Ok(RoomMessageEventContent::text_plain(format!("Invalid json: {e}"))), + } + } else { + Ok(RoomMessageEventContent::text_plain( + "Expected code block in command body. Add --help for details.", + )) + } + }, + FederationCommand::VerifyJson => { + if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" { + let string = body[1..body.len() - 1].join("\n"); + match serde_json::from_str(&string) { + Ok(value) => { + let pub_key_map = RwLock::new(BTreeMap::new()); + + services().rooms.event_handler.fetch_required_signing_keys([&value], &pub_key_map).await?; + + let pub_key_map = pub_key_map.read().await; + match ruma::signatures::verify_json(&pub_key_map, &value) { + Ok(()) => Ok(RoomMessageEventContent::text_plain("Signature correct")), + Err(e) => Ok(RoomMessageEventContent::text_plain(format!( + "Signature verification failed: {e}" + ))), + } + }, + Err(e) => Ok(RoomMessageEventContent::text_plain(format!("Invalid json: {e}"))), + } + } else { + Ok(RoomMessageEventContent::text_plain( + "Expected code block in command body. Add --help for details.", + )) + } + }, + } +} diff --git a/src/service/admin/media.rs b/src/service/admin/media.rs new file mode 100644 index 000000000..f467c0dd8 --- /dev/null +++ b/src/service/admin/media.rs @@ -0,0 +1,213 @@ +use clap::Subcommand; +use ruma::{events::room::message::RoomMessageEventContent, EventId}; +use tracing::{debug, info}; + +use crate::{service::admin::MxcUri, services, Result}; + +#[cfg_attr(test, derive(Debug))] +#[derive(Subcommand)] +pub(crate) enum MediaCommand { + /// - Deletes a single media file from our database and on the filesystem + /// via a single MXC URL + Delete { + /// The MXC URL to delete + #[arg(long)] + mxc: Option>, + + /// - The message event ID which contains the media and thumbnail MXC + /// URLs + #[arg(long)] + event_id: Option>, + }, + + /// - Deletes a codeblock list of MXC URLs from our database and on the + /// filesystem + DeleteList, + + /// - Deletes all remote media in the last X amount of time using filesystem + /// metadata first created at date. + DeletePastRemoteMedia { + /// - The duration (at or after), e.g. "5m" to delete all media in the + /// past 5 minutes + duration: String, + }, +} + +pub(crate) async fn process(command: MediaCommand, body: Vec<&str>) -> Result { + match command { + MediaCommand::Delete { + mxc, + event_id, + } => { + if event_id.is_some() && mxc.is_some() { + return Ok(RoomMessageEventContent::text_plain( + "Please specify either an MXC or an event ID, not both.", + )); + } + + if let Some(mxc) = mxc { + if !mxc.to_string().starts_with("mxc://") { + return Ok(RoomMessageEventContent::text_plain("MXC provided is not valid.")); + } + + debug!("Got MXC URL: {}", mxc); + services().media.delete(mxc.to_string()).await?; + + return Ok(RoomMessageEventContent::text_plain( + "Deleted the MXC from our database and on our filesystem.", + )); + } else if let Some(event_id) = event_id { + debug!("Got event ID to delete media from: {}", event_id); + + let mut mxc_urls = vec![]; + let mut mxc_deletion_count = 0; + + // parsing the PDU for any MXC URLs begins here + if let Some(event_json) = services().rooms.timeline.get_pdu_json(&event_id)? { + if let Some(content_key) = event_json.get("content") { + debug!("Event ID has \"content\"."); + let content_obj = content_key.as_object(); + + if let Some(content) = content_obj { + // 1. attempts to parse the "url" key + debug!("Attempting to go into \"url\" key for main media file"); + if let Some(url) = content.get("url") { + debug!("Got a URL in the event ID {event_id}: {url}"); + + if url.to_string().starts_with("\"mxc://") { + debug!("Pushing URL {} to list of MXCs to delete", url); + let final_url = url.to_string().replace('"', ""); + mxc_urls.push(final_url); + } else { + info!( + "Found a URL in the event ID {event_id} but did not start with mxc://, \ + ignoring" + ); + } + } + + // 2. attempts to parse the "info" key + debug!("Attempting to go into \"info\" key for thumbnails"); + if let Some(info_key) = content.get("info") { + debug!("Event ID has \"info\"."); + let info_obj = info_key.as_object(); + + if let Some(info) = info_obj { + if let Some(thumbnail_url) = info.get("thumbnail_url") { + debug!("Found a thumbnail_url in info key: {thumbnail_url}"); + + if thumbnail_url.to_string().starts_with("\"mxc://") { + debug!("Pushing thumbnail URL {} to list of MXCs to delete", thumbnail_url); + let final_thumbnail_url = thumbnail_url.to_string().replace('"', ""); + mxc_urls.push(final_thumbnail_url); + } else { + info!( + "Found a thumbnail URL in the event ID {event_id} but did not start \ + with mxc://, ignoring" + ); + } + } else { + info!("No \"thumbnail_url\" key in \"info\" key, assuming no thumbnails."); + } + } + } + + // 3. attempts to parse the "file" key + debug!("Attempting to go into \"file\" key"); + if let Some(file_key) = content.get("file") { + debug!("Event ID has \"file\"."); + let file_obj = file_key.as_object(); + + if let Some(file) = file_obj { + if let Some(url) = file.get("url") { + debug!("Found url in file key: {url}"); + + if url.to_string().starts_with("\"mxc://") { + debug!("Pushing URL {} to list of MXCs to delete", url); + let final_url = url.to_string().replace('"', ""); + mxc_urls.push(final_url); + } else { + info!( + "Found a URL in the event ID {event_id} but did not start with \ + mxc://, ignoring" + ); + } + } else { + info!("No \"url\" key in \"file\" key."); + } + } + } + } else { + return Ok(RoomMessageEventContent::text_plain( + "Event ID does not have a \"content\" key or failed parsing the event ID JSON.", + )); + } + } else { + return Ok(RoomMessageEventContent::text_plain( + "Event ID does not have a \"content\" key, this is not a message or an event type that \ + contains media.", + )); + } + } else { + return Ok(RoomMessageEventContent::text_plain( + "Event ID does not exist or is not known to us.", + )); + } + + if mxc_urls.is_empty() { + // we shouldn't get here (should have errored earlier) but just in case for + // whatever reason we do... + info!("Parsed event ID {event_id} but did not contain any MXC URLs."); + return Ok(RoomMessageEventContent::text_plain("Parsed event ID but found no MXC URLs.")); + } + + for mxc_url in mxc_urls { + services().media.delete(mxc_url).await?; + mxc_deletion_count += 1; + } + + return Ok(RoomMessageEventContent::text_plain(format!( + "Deleted {mxc_deletion_count} total MXCs from our database and the filesystem from event ID \ + {event_id}." + ))); + } + + Ok(RoomMessageEventContent::text_plain( + "Please specify either an MXC using --mxc or an event ID using --event-id of the message containing \ + an image. See --help for details.", + )) + }, + MediaCommand::DeleteList => { + if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" { + let mxc_list = body.clone().drain(1..body.len() - 1).collect::>(); + + let mut mxc_deletion_count = 0; + + for mxc in mxc_list { + debug!("Deleting MXC {} in bulk", mxc); + services().media.delete(mxc.to_owned()).await?; + mxc_deletion_count += 1; + } + + return Ok(RoomMessageEventContent::text_plain(format!( + "Finished bulk MXC deletion, deleted {} total MXCs from our database and the filesystem.", + mxc_deletion_count + ))); + } + + Ok(RoomMessageEventContent::text_plain( + "Expected code block in command body. Add --help for details.", + )) + }, + MediaCommand::DeletePastRemoteMedia { + duration, + } => { + let deleted_count = services().media.delete_all_remote_media_at_after_time(duration).await?; + + Ok(RoomMessageEventContent::text_plain(format!( + "Deleted {} total files.", + deleted_count + ))) + }, + } +} diff --git a/src/service/admin/mod.rs b/src/service/admin/mod.rs index c13404623..c09bbacdf 100644 --- a/src/service/admin/mod.rs +++ b/src/service/admin/mod.rs @@ -1,9 +1,9 @@ -use std::{collections::BTreeMap, fmt::Write as _, sync::Arc, time::Instant}; +use std::{collections::BTreeMap, sync::Arc}; -use clap::{Parser, Subcommand}; +use clap::Parser; use regex::Regex; use ruma::{ - api::{appservice::Registration, client::error::ErrorKind}, + api::client::error::ErrorKind, events::{ relation::InReplyTo, room::{ @@ -20,26 +20,32 @@ use ruma::{ }, TimelineEventType, }, - CanonicalJsonObject, EventId, MxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomAliasId, RoomId, - RoomOrAliasId, RoomVersionId, ServerName, UserId, + EventId, MxcUri, OwnedRoomAliasId, OwnedRoomId, RoomAliasId, RoomId, RoomVersionId, ServerName, UserId, }; use serde_json::value::to_raw_value; -use tokio::sync::{mpsc, Mutex, RwLock}; -use tracing::{debug, error, info, warn}; +use tokio::sync::{mpsc, Mutex}; +use tracing::warn; use super::pdu::PduBuilder; use crate::{ - api::{ - client_server::{ - get_alias_helper, join_room_by_id_helper, leave_all_rooms, leave_room, AUTO_GEN_PASSWORD_LENGTH, - }, - server_server::parse_incoming_pdu, + service::admin::{ + appservice::AppserviceCommand, debug::DebugCommand, federation::FederationCommand, media::MediaCommand, + room::RoomCommand, server::ServerCommand, user::UserCommand, }, - services, - utils::{self, HtmlEscape}, - Error, PduEvent, Result, + services, Error, Result, }; +pub(crate) mod appservice; +pub(crate) mod debug; +pub(crate) mod federation; +pub(crate) mod media; +pub(crate) mod room; +pub(crate) mod room_alias; +pub(crate) mod room_directory; +pub(crate) mod room_moderation; +pub(crate) mod server; +pub(crate) mod user; + const PAGE_SIZE: usize = 100; #[cfg_attr(test, derive(Debug))] @@ -78,375 +84,6 @@ enum AdminCommand { Debug(DebugCommand), } -#[cfg_attr(test, derive(Debug))] -#[derive(Subcommand)] -enum MediaCommand { - /// - Deletes a single media file from our database and on the filesystem - /// via a single MXC URL - Delete { - /// The MXC URL to delete - #[arg(long)] - mxc: Option>, - - /// - The message event ID which contains the media and thumbnail MXC - /// URLs - #[arg(long)] - event_id: Option>, - }, - - /// - Deletes a codeblock list of MXC URLs from our database and on the - /// filesystem - DeleteList, - - /// - Deletes all remote media in the last X amount of time using filesystem - /// metadata first created at date. - DeletePastRemoteMedia { - /// - The duration (at or after), e.g. "5m" to delete all media in the - /// past 5 minutes - duration: String, - }, -} - -#[cfg_attr(test, derive(Debug))] -#[derive(Subcommand)] -enum AppserviceCommand { - /// - Register an appservice using its registration YAML - /// - /// This command needs a YAML generated by an appservice (such as a bridge), - /// which must be provided in a Markdown code block below the command. - /// - /// Registering a new bridge using the ID of an existing bridge will replace - /// the old one. - Register, - - /// - Unregister an appservice using its ID - /// - /// You can find the ID using the `list-appservices` command. - Unregister { - /// The appservice to unregister - appservice_identifier: String, - }, - - /// - Show an appservice's config using its ID - /// - /// You can find the ID using the `list-appservices` command. - Show { - /// The appservice to show - appservice_identifier: String, - }, - - /// - List all the currently registered appservices - List, -} - -#[cfg_attr(test, derive(Debug))] -#[derive(Subcommand)] -enum UserCommand { - /// - Create a new user - Create { - /// Username of the new user - username: String, - /// Password of the new user, if unspecified one is generated - password: Option, - }, - - /// - Reset user password - ResetPassword { - /// Username of the user for whom the password should be reset - username: String, - }, - - /// - Deactivate a user - /// - /// User will not be removed from all rooms by default. - /// Use --leave-rooms to force the user to leave all rooms - Deactivate { - #[arg(short, long)] - leave_rooms: bool, - user_id: Box, - }, - - /// - Deactivate a list of users - /// - /// Recommended to use in conjunction with list-local-users. - /// - /// Users will not be removed from joined rooms by default. - /// Can be overridden with --leave-rooms flag. - /// Removing a mass amount of users from a room may cause a significant - /// amount of leave events. The time to leave rooms may depend significantly - /// on joined rooms and servers. - /// - /// This command needs a newline separated list of users provided in a - /// Markdown code block below the command. - DeactivateAll { - #[arg(short, long)] - /// Remove users from their joined rooms - leave_rooms: bool, - #[arg(short, long)] - /// Also deactivate admin accounts - force: bool, - }, - - /// - List local users in the database - List, - - /// - Lists all the rooms (local and remote) that the specified user is - /// joined in - ListJoinedRooms { - user_id: Box, - }, -} - -#[cfg_attr(test, derive(Debug))] -#[derive(Subcommand)] -enum RoomCommand { - /// - List all rooms the server knows about - List { - page: Option, - }, - - #[command(subcommand)] - /// - Manage moderation of remote or local rooms - Moderation(RoomModeration), - - #[command(subcommand)] - /// - Manage rooms' aliases - Alias(RoomAliasCommand), - - #[command(subcommand)] - /// - Manage the room directory - Directory(RoomDirectoryCommand), -} - -#[cfg_attr(test, derive(Debug))] -#[derive(Subcommand)] -enum RoomModeration { - /// - Bans a room from local users joining and evicts all our local users - /// from the room. Also blocks any invites (local and remote) for the - /// banned room. - /// - /// Server admins (users in the conduwuit admin room) will not be evicted - /// and server admins can still join the room. To evict admins too, use - /// --force (also ignores errors) To disable incoming federation of the - /// room, use --disable-federation - BanRoom { - #[arg(short, long)] - /// Evicts admins out of the room and ignores any potential errors when - /// making our local users leave the room - force: bool, - - #[arg(long)] - /// Disables incoming federation of the room after banning and evicting - /// users - disable_federation: bool, - - /// The room in the format of `!roomid:example.com` or a room alias in - /// the format of `#roomalias:example.com` - room: Box, - }, - - /// - Bans a list of rooms from a newline delimited codeblock similar to - /// `user deactivate-all` - BanListOfRooms { - #[arg(short, long)] - /// Evicts admins out of the room and ignores any potential errors when - /// making our local users leave the room - force: bool, - - #[arg(long)] - /// Disables incoming federation of the room after banning and evicting - /// users - disable_federation: bool, - }, - - /// - Unbans a room to allow local users to join again - /// - /// To re-enable incoming federation of the room, use --enable-federation - UnbanRoom { - #[arg(long)] - /// Enables incoming federation of the room after unbanning - enable_federation: bool, - - /// The room in the format of `!roomid:example.com` or a room alias in - /// the format of `#roomalias:example.com` - room: Box, - }, - - /// - List of all rooms we have banned - ListBannedRooms, -} - -#[cfg_attr(test, derive(Debug))] -#[derive(Subcommand)] -enum RoomAliasCommand { - /// - Make an alias point to a room. - Set { - #[arg(short, long)] - /// Set the alias even if a room is already using it - force: bool, - - /// The room id to set the alias on - room_id: Box, - - /// The alias localpart to use (`alias`, not `#alias:servername.tld`) - room_alias_localpart: String, - }, - - /// - Remove an alias - Remove { - /// The alias localpart to remove (`alias`, not `#alias:servername.tld`) - room_alias_localpart: String, - }, - - /// - Show which room is using an alias - Which { - /// The alias localpart to look up (`alias`, not - /// `#alias:servername.tld`) - room_alias_localpart: String, - }, - - /// - List aliases currently being used - List { - /// If set, only list the aliases for this room - room_id: Option>, - }, -} - -#[cfg_attr(test, derive(Debug))] -#[derive(Subcommand)] -enum RoomDirectoryCommand { - /// - Publish a room to the room directory - Publish { - /// The room id of the room to publish - room_id: Box, - }, - - /// - Unpublish a room to the room directory - Unpublish { - /// The room id of the room to unpublish - room_id: Box, - }, - - /// - List rooms that are published - List { - page: Option, - }, -} - -#[cfg_attr(test, derive(Debug))] -#[derive(Subcommand)] -enum FederationCommand { - /// - List all rooms we are currently handling an incoming pdu from - IncomingFederation, - - /// - Disables incoming federation handling for a room. - DisableRoom { - room_id: Box, - }, - - /// - Enables incoming federation handling for a room again. - EnableRoom { - room_id: Box, - }, - - /// - Verify json signatures - /// - /// This command needs a JSON blob provided in a Markdown code block below - /// the command. - SignJson, - - /// - Verify json signatures - /// - /// This command needs a JSON blob provided in a Markdown code block below - /// the command. - VerifyJson, -} - -#[cfg_attr(test, derive(Debug))] -#[derive(Subcommand)] -enum DebugCommand { - /// - Get the auth_chain of a PDU - GetAuthChain { - /// An event ID (the $ character followed by the base64 reference hash) - event_id: Box, - }, - - /// - Parse and print a PDU from a JSON - /// - /// The PDU event is only checked for validity and is not added to the - /// database. - /// - /// This command needs a JSON blob provided in a Markdown code block below - /// the command. - ParsePdu, - - /// - Retrieve and print a PDU by ID from the conduwuit database - GetPdu { - /// An event ID (a $ followed by the base64 reference hash) - event_id: Box, - }, - - /// - Attempts to retrieve a PDU from a remote server. Inserts it into our - /// database/timeline if found and we do not have this PDU already - /// (following normal event auth rules, handles it as an incoming PDU). - GetRemotePdu { - /// An event ID (a $ followed by the base64 reference hash) - event_id: Box, - - /// Argument for us to attempt to fetch the event from the - /// specified remote server. - server: Box, - }, - - /// - Gets all the room state events for the specified room. - /// - /// This is functionally equivalent to `GET - /// /_matrix/client/v3/rooms/{roomid}/state`, except the admin command does - /// *not* check if the sender user is allowed to see state events. This is - /// done because it's implied that server admins here have database access - /// and can see/get room info themselves anyways if they were malicious - /// admins. - /// - /// Of course the check is still done on the actual client API. - GetRoomState { - /// Room ID - room_id: Box, - }, - - /// - Forces device lists for all local and remote users to be updated (as - /// having new keys available) - ForceDeviceListUpdates, -} - -#[cfg_attr(test, derive(Debug))] -#[derive(Subcommand)] -enum ServerCommand { - /// - Show configuration values - ShowConfig, - - /// - Print database memory usage statistics - MemoryUsage, - - /// - Clears all of Conduit's database caches with index smaller than the - /// amount - ClearDatabaseCaches { - amount: u32, - }, - - /// - Clears all of Conduit's service caches with index smaller than the - /// amount - ClearServiceCaches { - amount: u32, - }, - - /// - Performs an online backup of the database (only available for RocksDB - /// at the moment) - BackupDatabase, - - /// - List database backups - ListBackups, -} - #[derive(Debug)] pub enum AdminRoomEvent { ProcessMessage(String, Arc), @@ -589,1589 +226,20 @@ impl Service { AdminCommand::try_parse_from(argv).map_err(|error| error.to_string()) } - #[allow(clippy::too_many_lines)] async fn process_admin_command(&self, command: AdminCommand, body: Vec<&str>) -> Result { let reply_message_content = match command { - AdminCommand::Appservices(command) => match command { - AppserviceCommand::Register => { - if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" { - let appservice_config = body[1..body.len() - 1].join("\n"); - let parsed_config = serde_yaml::from_str::(&appservice_config); - match parsed_config { - Ok(yaml) => match services().appservice.register_appservice(yaml).await { - Ok(id) => { - RoomMessageEventContent::text_plain(format!("Appservice registered with ID: {id}.")) - }, - Err(e) => { - RoomMessageEventContent::text_plain(format!("Failed to register appservice: {e}")) - }, - }, - Err(e) => { - RoomMessageEventContent::text_plain(format!("Could not parse appservice config: {e}")) - }, - } - } else { - RoomMessageEventContent::text_plain( - "Expected code block in command body. Add --help for details.", - ) - } - }, - AppserviceCommand::Unregister { - appservice_identifier, - } => match services().appservice.unregister_appservice(&appservice_identifier).await { - Ok(()) => RoomMessageEventContent::text_plain("Appservice unregistered."), - Err(e) => RoomMessageEventContent::text_plain(format!("Failed to unregister appservice: {e}")), - }, - AppserviceCommand::Show { - appservice_identifier, - } => match services().appservice.get_registration(&appservice_identifier).await { - Some(config) => { - let config_str = - serde_yaml::to_string(&config).expect("config should've been validated on register"); - let output = format!("Config for {}:\n\n```yaml\n{}\n```", appservice_identifier, config_str,); - let output_html = format!( - "Config for {}:\n\n
{}
", - escape_html(&appservice_identifier), - escape_html(&config_str), - ); - RoomMessageEventContent::text_html(output, output_html) - }, - None => RoomMessageEventContent::text_plain("Appservice does not exist."), - }, - AppserviceCommand::List => { - let appservices = services().appservice.iter_ids().await; - let output = format!("Appservices ({}): {}", appservices.len(), appservices.join(", ")); - RoomMessageEventContent::text_plain(output) - }, - }, - AdminCommand::Media(command) => { - match command { - MediaCommand::Delete { - mxc, - event_id, - } => { - if event_id.is_some() && mxc.is_some() { - return Ok(RoomMessageEventContent::text_plain( - "Please specify either an MXC or an event ID, not both.", - )); - } - - if let Some(mxc) = mxc { - if !mxc.to_string().starts_with("mxc://") { - return Ok(RoomMessageEventContent::text_plain("MXC provided is not valid.")); - } - - debug!("Got MXC URL: {}", mxc); - services().media.delete(mxc.to_string()).await?; - - return Ok(RoomMessageEventContent::text_plain( - "Deleted the MXC from our database and on our filesystem.", - )); - } else if let Some(event_id) = event_id { - debug!("Got event ID to delete media from: {}", event_id); - - let mut mxc_urls = vec![]; - let mut mxc_deletion_count = 0; - - // parsing the PDU for any MXC URLs begins here - if let Some(event_json) = services().rooms.timeline.get_pdu_json(&event_id)? { - if let Some(content_key) = event_json.get("content") { - debug!("Event ID has \"content\"."); - let content_obj = content_key.as_object(); - - if let Some(content) = content_obj { - // 1. attempts to parse the "url" key - debug!("Attempting to go into \"url\" key for main media file"); - if let Some(url) = content.get("url") { - debug!("Got a URL in the event ID {event_id}: {url}"); - - if url.to_string().starts_with("\"mxc://") { - debug!("Pushing URL {} to list of MXCs to delete", url); - let final_url = url.to_string().replace('"', ""); - mxc_urls.push(final_url); - } else { - info!( - "Found a URL in the event ID {event_id} but did not start with \ - mxc://, ignoring" - ); - } - } - - // 2. attempts to parse the "info" key - debug!("Attempting to go into \"info\" key for thumbnails"); - if let Some(info_key) = content.get("info") { - debug!("Event ID has \"info\"."); - let info_obj = info_key.as_object(); - - if let Some(info) = info_obj { - if let Some(thumbnail_url) = info.get("thumbnail_url") { - debug!("Found a thumbnail_url in info key: {thumbnail_url}"); - - if thumbnail_url.to_string().starts_with("\"mxc://") { - debug!( - "Pushing thumbnail URL {} to list of MXCs to delete", - thumbnail_url - ); - let final_thumbnail_url = - thumbnail_url.to_string().replace('"', ""); - mxc_urls.push(final_thumbnail_url); - } else { - info!( - "Found a thumbnail URL in the event ID {event_id} but did \ - not start with mxc://, ignoring" - ); - } - } else { - info!( - "No \"thumbnail_url\" key in \"info\" key, assuming no \ - thumbnails." - ); - } - } - } - - // 3. attempts to parse the "file" key - debug!("Attempting to go into \"file\" key"); - if let Some(file_key) = content.get("file") { - debug!("Event ID has \"file\"."); - let file_obj = file_key.as_object(); - - if let Some(file) = file_obj { - if let Some(url) = file.get("url") { - debug!("Found url in file key: {url}"); - - if url.to_string().starts_with("\"mxc://") { - debug!("Pushing URL {} to list of MXCs to delete", url); - let final_url = url.to_string().replace('"', ""); - mxc_urls.push(final_url); - } else { - info!( - "Found a URL in the event ID {event_id} but did not start \ - with mxc://, ignoring" - ); - } - } else { - info!("No \"url\" key in \"file\" key."); - } - } - } - } else { - return Ok(RoomMessageEventContent::text_plain( - "Event ID does not have a \"content\" key or failed parsing the event ID \ - JSON.", - )); - } - } else { - return Ok(RoomMessageEventContent::text_plain( - "Event ID does not have a \"content\" key, this is not a message or an event \ - type that contains media.", - )); - } - } else { - return Ok(RoomMessageEventContent::text_plain( - "Event ID does not exist or is not known to us.", - )); - } - - if mxc_urls.is_empty() { - // we shouldn't get here (should have errored earlier) but just in case for - // whatever reason we do... - info!("Parsed event ID {event_id} but did not contain any MXC URLs."); - return Ok(RoomMessageEventContent::text_plain( - "Parsed event ID but found no MXC URLs.", - )); - } - - for mxc_url in mxc_urls { - services().media.delete(mxc_url).await?; - mxc_deletion_count += 1; - } - - return Ok(RoomMessageEventContent::text_plain(format!( - "Deleted {mxc_deletion_count} total MXCs from our database and the filesystem from \ - event ID {event_id}." - ))); - } - - return Ok(RoomMessageEventContent::text_plain( - "Please specify either an MXC using --mxc or an event ID using --event-id of the message \ - containing an image. See --help for details.", - )); - }, - MediaCommand::DeleteList => { - if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" { - let mxc_list = body.clone().drain(1..body.len() - 1).collect::>(); - - let mut mxc_deletion_count = 0; - - for mxc in mxc_list { - debug!("Deleting MXC {} in bulk", mxc); - services().media.delete(mxc.to_owned()).await?; - mxc_deletion_count += 1; - } - - return Ok(RoomMessageEventContent::text_plain(format!( - "Finished bulk MXC deletion, deleted {} total MXCs from our database and the \ - filesystem.", - mxc_deletion_count - ))); - } - - return Ok(RoomMessageEventContent::text_plain( - "Expected code block in command body. Add --help for details.", - )); - }, - MediaCommand::DeletePastRemoteMedia { - duration, - } => { - let deleted_count = services().media.delete_all_remote_media_at_after_time(duration).await?; - - return Ok(RoomMessageEventContent::text_plain(format!( - "Deleted {} total files.", - deleted_count - ))); - }, - } - }, - AdminCommand::Users(command) => match command { - UserCommand::List => match services().users.list_local_users() { - Ok(users) => { - let mut msg = format!("Found {} local user account(s):\n", users.len()); - msg += &users.join("\n"); - RoomMessageEventContent::text_plain(&msg) - }, - Err(e) => RoomMessageEventContent::text_plain(e.to_string()), - }, - UserCommand::Create { - username, - password, - } => { - let password = password.unwrap_or_else(|| utils::random_string(AUTO_GEN_PASSWORD_LENGTH)); - // Validate user id - let user_id = match UserId::parse_with_server_name( - username.as_str().to_lowercase(), - services().globals.server_name(), - ) { - Ok(id) => id, - Err(e) => { - return Ok(RoomMessageEventContent::text_plain(format!( - "The supplied username is not a valid username: {e}" - ))) - }, - }; - if user_id.is_historical() { - return Ok(RoomMessageEventContent::text_plain(format!( - "Userid {user_id} is not allowed due to historical" - ))); - } - if services().users.exists(&user_id)? { - return Ok(RoomMessageEventContent::text_plain(format!("Userid {user_id} already exists"))); - } - // Create user - services().users.create(&user_id, Some(password.as_str()))?; - - // Default to pretty displayname - let mut displayname = user_id.localpart().to_owned(); - - // If `new_user_displayname_suffix` is set, registration will push whatever - // content is set to the user's display name with a space before it - if !services().globals.new_user_displayname_suffix().is_empty() { - displayname.push_str(&(" ".to_owned() + services().globals.new_user_displayname_suffix())); - } - - services().users.set_displayname(&user_id, Some(displayname)).await?; - - // Initial account data - services().account_data.update( - None, - &user_id, - ruma::events::GlobalAccountDataEventType::PushRules.to_string().into(), - &serde_json::to_value(ruma::events::push_rules::PushRulesEvent { - content: ruma::events::push_rules::PushRulesEventContent { - global: ruma::push::Ruleset::server_default(&user_id), - }, - }) - .expect("to json value always works"), - )?; - - if !services().globals.config.auto_join_rooms.is_empty() { - for room in &services().globals.config.auto_join_rooms { - if !services().rooms.state_cache.server_in_room(services().globals.server_name(), room)? { - warn!("Skipping room {room} to automatically join as we have never joined before."); - continue; - } - - if let Some(room_id_server_name) = room.server_name() { - match join_room_by_id_helper( - Some(&user_id), - room, - Some("Automatically joining this room upon registration".to_owned()), - &[room_id_server_name.to_owned(), services().globals.server_name().to_owned()], - None, - ) - .await - { - Ok(_) => { - info!("Automatically joined room {room} for user {user_id}"); - }, - Err(e) => { - // don't return this error so we don't fail registrations - error!("Failed to automatically join room {room} for user {user_id}: {e}"); - }, - }; - } - } - } - - // we dont add a device since we're not the user, just the creator - - // Inhibit login does not work for guests - RoomMessageEventContent::text_plain(format!( - "Created user with user_id: {user_id} and password: `{password}`" - )) - }, - UserCommand::Deactivate { - leave_rooms, - user_id, - } => { - let user_id = Arc::::from(user_id); - - // check if user belongs to our server - if user_id.server_name() != services().globals.server_name() { - return Ok(RoomMessageEventContent::text_plain(format!( - "User {user_id} does not belong to our server." - ))); - } - - if services().users.exists(&user_id)? { - RoomMessageEventContent::text_plain(format!( - "Making {user_id} leave all rooms before deactivation..." - )); - - services().users.deactivate_account(&user_id)?; - - if leave_rooms { - leave_all_rooms(&user_id).await?; - } - - RoomMessageEventContent::text_plain(format!("User {user_id} has been deactivated")) - } else { - RoomMessageEventContent::text_plain(format!("User {user_id} doesn't exist on this server")) - } - }, - UserCommand::ResetPassword { - username, - } => { - let user_id = match UserId::parse_with_server_name( - username.as_str().to_lowercase(), - services().globals.server_name(), - ) { - Ok(id) => id, - Err(e) => { - return Ok(RoomMessageEventContent::text_plain(format!( - "The supplied username is not a valid username: {e}" - ))) - }, - }; - - // check if user belongs to our server - if user_id.server_name() != services().globals.server_name() { - return Ok(RoomMessageEventContent::text_plain(format!( - "User {user_id} does not belong to our server." - ))); - } - - // Check if the specified user is valid - if !services().users.exists(&user_id)? - || user_id - == UserId::parse_with_server_name("conduit", services().globals.server_name()) - .expect("conduit user exists") - { - return Ok(RoomMessageEventContent::text_plain("The specified user does not exist!")); - } - - let new_password = utils::random_string(AUTO_GEN_PASSWORD_LENGTH); - - match services().users.set_password(&user_id, Some(new_password.as_str())) { - Ok(()) => RoomMessageEventContent::text_plain(format!( - "Successfully reset the password for user {user_id}: `{new_password}`" - )), - Err(e) => RoomMessageEventContent::text_plain(format!( - "Couldn't reset the password for user {user_id}: {e}" - )), - } - }, - UserCommand::DeactivateAll { - leave_rooms, - force, - } => { - if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" { - let usernames = body.clone().drain(1..body.len() - 1).collect::>(); - - let mut user_ids: Vec<&UserId> = Vec::new(); - - for &username in &usernames { - match <&UserId>::try_from(username) { - Ok(user_id) => user_ids.push(user_id), - Err(e) => { - return Ok(RoomMessageEventContent::text_plain(format!( - "{username} is not a valid username: {e}" - ))) - }, - } - } - - let mut deactivation_count = 0; - let mut admins = Vec::new(); - - if !force { - user_ids.retain(|&user_id| match services().users.is_admin(user_id) { - Ok(is_admin) => { - if is_admin { - admins.push(user_id.localpart()); - false - } else { - true - } - }, - Err(_) => false, - }); - } - - for &user_id in &user_ids { - // check if user belongs to our server and skips over non-local users - if user_id.server_name() != services().globals.server_name() { - continue; - } - - if services().users.deactivate_account(user_id).is_ok() { - deactivation_count += 1; - } - } - - if leave_rooms { - for &user_id in &user_ids { - _ = leave_all_rooms(user_id).await; - } - } - - if admins.is_empty() { - RoomMessageEventContent::text_plain(format!("Deactivated {deactivation_count} accounts.")) - } else { - RoomMessageEventContent::text_plain(format!( - "Deactivated {} accounts.\nSkipped admin accounts: {:?}. Use --force to deactivate \ - admin accounts", - deactivation_count, - admins.join(", ") - )) - } - } else { - RoomMessageEventContent::text_plain( - "Expected code block in command body. Add --help for details.", - ) - } - }, - UserCommand::ListJoinedRooms { - user_id, - } => { - if user_id.server_name() != services().globals.server_name() { - return Ok(RoomMessageEventContent::text_plain("User does not belong to our server.")); - } - - let mut rooms = vec![]; // room ID, members joined, room name - - for room_id in services().rooms.state_cache.rooms_joined(&user_id) { - let room_id = room_id?; - rooms.push(Self::get_room_info(&room_id)); - } - - if rooms.is_empty() { - return Ok(RoomMessageEventContent::text_plain("User is not in any rooms.")); - } - - rooms.sort_by_key(|r| r.1); - rooms.reverse(); - - let output_plain = format!( - "Rooms {user_id} Joined:\n{}", - rooms - .iter() - .map(|(id, members, name)| format!("{id}\tMembers: {members}\tName: {name}")) - .collect::>() - .join("\n") - ); - let output_html = format!( - "\n\t\t\n{}
Rooms {user_id} \ - Joined
idmembersname
", - rooms.iter().fold(String::new(), |mut output, (id, members, name)| { - writeln!( - output, - "{}\t{}\t{}", - escape_html(id.as_ref()), - members, - escape_html(name) - ) - .unwrap(); - output - }) - ); - RoomMessageEventContent::text_html(output_plain, output_html) - }, - }, - AdminCommand::Rooms(command) => match command { - RoomCommand::Moderation(command) => { - match command { - RoomModeration::BanRoom { - force, - room, - disable_federation, - } => { - debug!("Got room alias or ID: {}", room); - - let admin_room_alias: Box = - format!("#admins:{}", services().globals.server_name()) - .try_into() - .expect("#admins:server_name is a valid alias name"); - - if let Some(admin_room_id) = Self::get_admin_room()? { - if room.to_string().eq(&admin_room_id) || room.to_string().eq(&admin_room_alias) { - return Ok(RoomMessageEventContent::text_plain( - "Not allowed to ban the admin room.", - )); - } - } - - let room_id = if room.is_room_id() { - let room_id = match RoomId::parse(&room) { - Ok(room_id) => room_id, - Err(e) => { - return Ok(RoomMessageEventContent::text_plain(format!( - "Failed to parse room ID {room}. Please note that this requires a full \ - room ID (`!awIh6gGInaS5wLQJwa:example.com`) or a room alias \ - (`#roomalias:example.com`): {e}" - ))) - }, - }; - - debug!("Room specified is a room ID, banning room ID"); - - services().rooms.metadata.ban_room(&room_id, true)?; - - room_id - } else if room.is_room_alias_id() { - let room_alias = match RoomAliasId::parse(&room) { - Ok(room_alias) => room_alias, - Err(e) => { - return Ok(RoomMessageEventContent::text_plain(format!( - "Failed to parse room ID {room}. Please note that this requires a full \ - room ID (`!awIh6gGInaS5wLQJwa:example.com`) or a room alias \ - (`#roomalias:example.com`): {e}" - ))) - }, - }; - - debug!( - "Room specified is not a room ID, attempting to resolve room alias to a room ID \ - locally, if not using get_alias_helper to fetch room ID remotely" - ); - - let room_id = match services().rooms.alias.resolve_local_alias(&room_alias)? { - Some(room_id) => room_id, - None => { - debug!( - "We don't have this room alias to a room ID locally, attempting to fetch \ - room ID over federation" - ); - - match get_alias_helper(room_alias).await { - Ok(response) => { - debug!( - "Got federation response fetching room ID for room {room}: {:?}", - response - ); - response.room_id - }, - Err(e) => { - return Ok(RoomMessageEventContent::text_plain(format!( - "Failed to resolve room alias {room} to a room ID: {e}" - ))); - }, - } - }, - }; - - services().rooms.metadata.ban_room(&room_id, true)?; - - room_id - } else { - return Ok(RoomMessageEventContent::text_plain( - "Room specified is not a room ID or room alias. Please note that this requires a \ - full room ID (`!awIh6gGInaS5wLQJwa:example.com`) or a room alias \ - (`#roomalias:example.com`)", - )); - }; - - debug!("Making all users leave the room {}", &room); - if force { - for local_user in services() - .rooms - .state_cache - .room_members(&room_id) - .filter_map(|user| { - user.ok().filter(|local_user| { - local_user.server_name() == services().globals.server_name() - // additional wrapped check here is to avoid adding remote users - // who are in the admin room to the list of local users (would fail auth check) - && (local_user.server_name() - == services().globals.server_name() - && services() - .users - .is_admin(local_user) - .unwrap_or(true)) // since this is a force operation, assume user is an admin if somehow this fails - }) - }) - .collect::>() - { - debug!( - "Attempting leave for user {} in room {} (forced, ignoring all errors, \ - evicting admins too)", - &local_user, &room_id - ); - - _ = leave_room(&local_user, &room_id, None).await; - } - } else { - for local_user in services() - .rooms - .state_cache - .room_members(&room_id) - .filter_map(|user| { - user.ok().filter(|local_user| { - local_user.server_name() == services().globals.server_name() - // additional wrapped check here is to avoid adding remote users - // who are in the admin room to the list of local users (would fail auth check) - && (local_user.server_name() - == services().globals.server_name() - && !services() - .users - .is_admin(local_user) - .unwrap_or(false)) - }) - }) - .collect::>() - { - debug!("Attempting leave for user {} in room {}", &local_user, &room_id); - if let Err(e) = leave_room(&local_user, &room_id, None).await { - error!( - "Error attempting to make local user {} leave room {} during room \ - banning: {}", - &local_user, &room_id, e - ); - return Ok(RoomMessageEventContent::text_plain(format!( - "Error attempting to make local user {} leave room {} during room banning \ - (room is still banned but not removing any more users): {}\nIf you would \ - like to ignore errors, use --force", - &local_user, &room_id, e - ))); - } - } - } - - if disable_federation { - services().rooms.metadata.disable_room(&room_id, true)?; - return Ok(RoomMessageEventContent::text_plain( - "Room banned, removed all our local users, and disabled incoming federation with \ - room.", - )); - } - - RoomMessageEventContent::text_plain( - "Room banned and removed all our local users, use disable-room to stop receiving new \ - inbound federation events as well if needed.", - ) - }, - RoomModeration::BanListOfRooms { - force, - disable_federation, - } => { - if body.len() > 2 - && body[0].trim().starts_with("```") - && body.last().unwrap().trim() == "```" - { - let rooms_s = body.clone().drain(1..body.len() - 1).collect::>(); - - let mut room_ban_count = 0; - let mut room_ids: Vec<&RoomId> = Vec::new(); - - for &room_id in &rooms_s { - match <&RoomId>::try_from(room_id) { - Ok(owned_room_id) => { - // silently ignore deleting admin room - if let Some(admin_room_id) = Self::get_admin_room()? { - if owned_room_id.eq(&admin_room_id) { - info!("User specified admin room in bulk ban list, ignoring"); - continue; - } - } - - room_ids.push(owned_room_id); - }, - Err(e) => { - if force { - // ignore rooms we failed to parse if we're force deleting - error!( - "Error parsing room ID {room_id} during bulk room banning, \ - ignoring error and logging here: {e}" - ); - continue; - } - - return Ok(RoomMessageEventContent::text_plain(format!( - "{room_id} is not a valid room ID, please fix the list and try again: \ - {e}" - ))); - }, - } - } - - for room_id in room_ids { - if services().rooms.metadata.ban_room(room_id, true).is_ok() { - debug!("Banned {room_id} successfully"); - room_ban_count += 1; - } - - debug!("Making all users leave the room {}", &room_id); - if force { - for local_user in services() - .rooms - .state_cache - .room_members(room_id) - .filter_map(|user| { - user.ok().filter(|local_user| { - local_user.server_name() == services().globals.server_name() - // additional wrapped check here is to avoid adding remote users - // who are in the admin room to the list of local users (would fail auth check) - && (local_user.server_name() - == services().globals.server_name() - && services() - .users - .is_admin(local_user) - .unwrap_or(true)) // since this is a force operation, assume user is an admin if somehow this fails - }) - }) - .collect::>() - { - debug!( - "Attempting leave for user {} in room {} (forced, ignoring all \ - errors, evicting admins too)", - &local_user, room_id - ); - _ = leave_room(&local_user, room_id, None).await; - } - } else { - for local_user in services() - .rooms - .state_cache - .room_members(room_id) - .filter_map(|user| { - user.ok().filter(|local_user| { - local_user.server_name() == services().globals.server_name() - // additional wrapped check here is to avoid adding remote users - // who are in the admin room to the list of local users (would fail auth check) - && (local_user.server_name() - == services().globals.server_name() - && !services() - .users - .is_admin(local_user) - .unwrap_or(false)) - }) - }) - .collect::>() - { - debug!("Attempting leave for user {} in room {}", &local_user, &room_id); - if let Err(e) = leave_room(&local_user, room_id, None).await { - error!( - "Error attempting to make local user {} leave room {} during bulk \ - room banning: {}", - &local_user, &room_id, e - ); - return Ok(RoomMessageEventContent::text_plain(format!( - "Error attempting to make local user {} leave room {} during room \ - banning (room is still banned but not removing any more users \ - and not banning any more rooms): {}\nIf you would like to ignore \ - errors, use --force", - &local_user, &room_id, e - ))); - } - } - } - - if disable_federation { - services().rooms.metadata.disable_room(room_id, true)?; - } - } - - if disable_federation { - return Ok(RoomMessageEventContent::text_plain(format!( - "Finished bulk room ban, banned {} total rooms, evicted all users, and \ - disabled incoming federation with the room.", - room_ban_count - ))); - } - return Ok(RoomMessageEventContent::text_plain(format!( - "Finished bulk room ban, banned {} total rooms and evicted all users.", - room_ban_count - ))); - } - - return Ok(RoomMessageEventContent::text_plain( - "Expected code block in command body. Add --help for details.", - )); - }, - RoomModeration::UnbanRoom { - room, - enable_federation, - } => { - let room_id = if room.is_room_id() { - let room_id = match RoomId::parse(&room) { - Ok(room_id) => room_id, - Err(e) => { - return Ok(RoomMessageEventContent::text_plain(format!( - "Failed to parse room ID {room}. Please note that this requires a full \ - room ID (`!awIh6gGInaS5wLQJwa:example.com`) or a room alias \ - (`#roomalias:example.com`): {e}" - ))) - }, - }; - - debug!("Room specified is a room ID, unbanning room ID"); - - services().rooms.metadata.ban_room(&room_id, false)?; - - room_id - } else if room.is_room_alias_id() { - let room_alias = match RoomAliasId::parse(&room) { - Ok(room_alias) => room_alias, - Err(e) => { - return Ok(RoomMessageEventContent::text_plain(format!( - "Failed to parse room ID {room}. Please note that this requires a full \ - room ID (`!awIh6gGInaS5wLQJwa:example.com`) or a room alias \ - (`#roomalias:example.com`): {e}" - ))) - }, - }; - - debug!( - "Room specified is not a room ID, attempting to resolve room alias to a room ID \ - locally, if not using get_alias_helper to fetch room ID remotely" - ); - - let room_id = match services().rooms.alias.resolve_local_alias(&room_alias)? { - Some(room_id) => room_id, - None => { - debug!( - "We don't have this room alias to a room ID locally, attempting to fetch \ - room ID over federation" - ); - - match get_alias_helper(room_alias).await { - Ok(response) => { - debug!( - "Got federation response fetching room ID for room {room}: {:?}", - response - ); - response.room_id - }, - Err(e) => { - return Ok(RoomMessageEventContent::text_plain(format!( - "Failed to resolve room alias {room} to a room ID: {e}" - ))); - }, - } - }, - }; - - services().rooms.metadata.ban_room(&room_id, false)?; - - room_id - } else { - return Ok(RoomMessageEventContent::text_plain( - "Room specified is not a room ID or room alias. Please note that this requires a \ - full room ID (`!awIh6gGInaS5wLQJwa:example.com`) or a room alias \ - (`#roomalias:example.com`)", - )); - }; - - if enable_federation { - services().rooms.metadata.disable_room(&room_id, false)?; - return Ok(RoomMessageEventContent::text_plain("Room unbanned.")); - } - - RoomMessageEventContent::text_plain( - "Room unbanned, you may need to re-enable federation with the room using enable-room \ - if this is a remote room to make it fully functional.", - ) - }, - RoomModeration::ListBannedRooms => { - let rooms = services().rooms.metadata.list_banned_rooms().collect::, _>>(); - - match rooms { - Ok(room_ids) => { - // TODO: add room name from our state cache if available, default to the room ID - // as the room name if we dont have it TODO: do same if we have a room alias for - // this - let plain_list = room_ids.iter().fold(String::new(), |mut output, room_id| { - writeln!(output, "- `{}`", room_id).unwrap(); - output - }); - - let html_list = room_ids.iter().fold(String::new(), |mut output, room_id| { - writeln!(output, "
  • {}
  • ", escape_html(room_id.as_ref())) - .unwrap(); - output - }); - - let plain = format!("Rooms:\n{}", plain_list); - let html = format!("Rooms:\n
      {}
    ", html_list); - RoomMessageEventContent::text_html(plain, html) - }, - Err(e) => { - error!("Failed to list banned rooms: {}", e); - RoomMessageEventContent::text_plain(format!("Unable to list room aliases: {}", e)) - }, - } - }, - } - }, - RoomCommand::List { - page, - } => { - // TODO: i know there's a way to do this with clap, but i can't seem to find it - let page = page.unwrap_or(1); - let mut rooms = services() - .rooms - .metadata - .iter_ids() - .filter_map(Result::ok) - .map(|id: OwnedRoomId| Self::get_room_info(&id)) - .collect::>(); - rooms.sort_by_key(|r| r.1); - rooms.reverse(); - - let rooms = - rooms.into_iter().skip(page.saturating_sub(1) * PAGE_SIZE).take(PAGE_SIZE).collect::>(); - - if rooms.is_empty() { - return Ok(RoomMessageEventContent::text_plain("No more rooms.")); - }; - - let output_plain = format!( - "Rooms:\n{}", - rooms - .iter() - .map(|(id, members, name)| format!("{id}\tMembers: {members}\tName: {name}")) - .collect::>() - .join("\n") - ); - let output_html = format!( - "\n\t\t\n{}
    Room list - page \ - {page}
    idmembersname
    ", - rooms.iter().fold(String::new(), |mut output, (id, members, name)| { - writeln!( - output, - "{}\t{}\t{}", - escape_html(id.as_ref()), - members, - escape_html(name) - ) - .unwrap(); - output - }) - ); - RoomMessageEventContent::text_html(output_plain, output_html) - }, - RoomCommand::Alias(command) => match command { - RoomAliasCommand::Set { - ref room_alias_localpart, - .. - } - | RoomAliasCommand::Remove { - ref room_alias_localpart, - } - | RoomAliasCommand::Which { - ref room_alias_localpart, - } => { - let room_alias_str = format!("#{}:{}", room_alias_localpart, services().globals.server_name()); - let room_alias = match RoomAliasId::parse_box(room_alias_str) { - Ok(alias) => alias, - Err(err) => { - return Ok(RoomMessageEventContent::text_plain(format!( - "Failed to parse alias: {}", - err - ))) - }, - }; - - match command { - RoomAliasCommand::Set { - force, - room_id, - .. - } => match (force, services().rooms.alias.resolve_local_alias(&room_alias)) { - (true, Ok(Some(id))) => match services().rooms.alias.set_alias(&room_alias, &room_id) { - Ok(()) => RoomMessageEventContent::text_plain(format!( - "Successfully overwrote alias (formerly {})", - id - )), - Err(err) => { - RoomMessageEventContent::text_plain(format!("Failed to remove alias: {}", err)) - }, - }, - (false, Ok(Some(id))) => RoomMessageEventContent::text_plain(format!( - "Refusing to overwrite in use alias for {}, use -f or --force to overwrite", - id - )), - (_, Ok(None)) => match services().rooms.alias.set_alias(&room_alias, &room_id) { - Ok(()) => RoomMessageEventContent::text_plain("Successfully set alias"), - Err(err) => { - RoomMessageEventContent::text_plain(format!("Failed to remove alias: {err}")) - }, - }, - (_, Err(err)) => { - RoomMessageEventContent::text_plain(format!("Unable to lookup alias: {err}")) - }, - }, - RoomAliasCommand::Remove { - .. - } => match services().rooms.alias.resolve_local_alias(&room_alias) { - Ok(Some(id)) => match services().rooms.alias.remove_alias(&room_alias) { - Ok(()) => RoomMessageEventContent::text_plain(format!("Removed alias from {}", id)), - Err(err) => { - RoomMessageEventContent::text_plain(format!("Failed to remove alias: {}", err)) - }, - }, - Ok(None) => RoomMessageEventContent::text_plain("Alias isn't in use."), - Err(err) => { - RoomMessageEventContent::text_plain(format!("Unable to lookup alias: {}", err)) - }, - }, - RoomAliasCommand::Which { - .. - } => match services().rooms.alias.resolve_local_alias(&room_alias) { - Ok(Some(id)) => { - RoomMessageEventContent::text_plain(format!("Alias resolves to {}", id)) - }, - Ok(None) => RoomMessageEventContent::text_plain("Alias isn't in use."), - Err(err) => { - RoomMessageEventContent::text_plain(format!("Unable to lookup alias: {}", err)) - }, - }, - RoomAliasCommand::List { - .. - } => unreachable!(), - } - }, - RoomAliasCommand::List { - room_id, - } => { - if let Some(room_id) = room_id { - let aliases = - services().rooms.alias.local_aliases_for_room(&room_id).collect::, _>>(); - match aliases { - Ok(aliases) => { - let plain_list = aliases.iter().fold(String::new(), |mut output, alias| { - writeln!(output, "- {alias}").unwrap(); - output - }); - - let html_list = aliases.iter().fold(String::new(), |mut output, alias| { - writeln!(output, "
  • {}
  • ", escape_html(alias.as_ref())).unwrap(); - output - }); - - let plain = format!("Aliases for {room_id}:\n{plain_list}"); - let html = format!("Aliases for {room_id}:\n
      {html_list}
    "); - RoomMessageEventContent::text_html(plain, html) - }, - Err(err) => { - RoomMessageEventContent::text_plain(format!("Unable to list aliases: {}", err)) - }, - } - } else { - let aliases = services().rooms.alias.all_local_aliases().collect::, _>>(); - match aliases { - Ok(aliases) => { - let server_name = services().globals.server_name(); - let plain_list = aliases.iter().fold(String::new(), |mut output, (alias, id)| { - writeln!(output, "- `{alias}` -> #{id}:{server_name}").unwrap(); - output - }); - - let html_list = aliases.iter().fold(String::new(), |mut output, (alias, id)| { - writeln!( - output, - "
  • {} -> #{}:{}
  • ", - escape_html(alias.as_ref()), - escape_html(id.as_ref()), - server_name - ) - .unwrap(); - output - }); - - let plain = format!("Aliases:\n{plain_list}"); - let html = format!("Aliases:\n
      {html_list}
    "); - RoomMessageEventContent::text_html(plain, html) - }, - Err(e) => { - RoomMessageEventContent::text_plain(format!("Unable to list room aliases: {e}")) - }, - } - } - }, - }, - RoomCommand::Directory(command) => match command { - RoomDirectoryCommand::Publish { - room_id, - } => match services().rooms.directory.set_public(&room_id) { - Ok(()) => RoomMessageEventContent::text_plain("Room published"), - Err(err) => RoomMessageEventContent::text_plain(format!("Unable to update room: {}", err)), - }, - RoomDirectoryCommand::Unpublish { - room_id, - } => match services().rooms.directory.set_not_public(&room_id) { - Ok(()) => RoomMessageEventContent::text_plain("Room unpublished"), - Err(err) => RoomMessageEventContent::text_plain(format!("Unable to update room: {}", err)), - }, - RoomDirectoryCommand::List { - page, - } => { - // TODO: i know there's a way to do this with clap, but i can't seem to find it - let page = page.unwrap_or(1); - let mut rooms = services() - .rooms - .directory - .public_rooms() - .filter_map(Result::ok) - .map(|id: OwnedRoomId| Self::get_room_info(&id)) - .collect::>(); - rooms.sort_by_key(|r| r.1); - rooms.reverse(); - - let rooms = rooms - .into_iter() - .skip(page.saturating_sub(1) * PAGE_SIZE) - .take(PAGE_SIZE) - .collect::>(); - - if rooms.is_empty() { - return Ok(RoomMessageEventContent::text_plain("No more rooms.")); - }; - - let output_plain = format!( - "Rooms:\n{}", - rooms - .iter() - .map(|(id, members, name)| format!("{id}\tMembers: {members}\tName: {name}")) - .collect::>() - .join("\n") - ); - let output_html = format!( - "\n\t\t\n{}
    Room directory - page \ - {page}
    idmembersname
    ", - rooms.iter().fold(String::new(), |mut output, (id, members, name)| { - writeln!( - output, - "{}\t{}\t{}", - escape_html(id.as_ref()), - members, - escape_html(name.as_ref()) - ) - .unwrap(); - output - }) - ); - RoomMessageEventContent::text_html(output_plain, output_html) - }, - }, - }, - AdminCommand::Federation(command) => match command { - FederationCommand::DisableRoom { - room_id, - } => { - services().rooms.metadata.disable_room(&room_id, true)?; - RoomMessageEventContent::text_plain("Room disabled.") - }, - FederationCommand::EnableRoom { - room_id, - } => { - services().rooms.metadata.disable_room(&room_id, false)?; - RoomMessageEventContent::text_plain("Room enabled.") - }, - FederationCommand::IncomingFederation => { - let map = services().globals.roomid_federationhandletime.read().await; - let mut msg = format!("Handling {} incoming pdus:\n", map.len()); - - for (r, (e, i)) in map.iter() { - let elapsed = i.elapsed(); - let _ = writeln!(msg, "{} {}: {}m{}s", r, e, elapsed.as_secs() / 60, elapsed.as_secs() % 60); - } - RoomMessageEventContent::text_plain(&msg) - }, - FederationCommand::SignJson => { - if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" { - let string = body[1..body.len() - 1].join("\n"); - match serde_json::from_str(&string) { - Ok(mut value) => { - ruma::signatures::sign_json( - services().globals.server_name().as_str(), - services().globals.keypair(), - &mut value, - ) - .expect("our request json is what ruma expects"); - let json_text = - serde_json::to_string_pretty(&value).expect("canonical json is valid json"); - RoomMessageEventContent::text_plain(json_text) - }, - Err(e) => RoomMessageEventContent::text_plain(format!("Invalid json: {e}")), - } - } else { - RoomMessageEventContent::text_plain( - "Expected code block in command body. Add --help for details.", - ) - } - }, - FederationCommand::VerifyJson => { - if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" { - let string = body[1..body.len() - 1].join("\n"); - match serde_json::from_str(&string) { - Ok(value) => { - let pub_key_map = RwLock::new(BTreeMap::new()); - - services() - .rooms - .event_handler - .fetch_required_signing_keys([&value], &pub_key_map) - .await?; - - let pub_key_map = pub_key_map.read().await; - match ruma::signatures::verify_json(&pub_key_map, &value) { - Ok(()) => RoomMessageEventContent::text_plain("Signature correct"), - Err(e) => RoomMessageEventContent::text_plain(format!( - "Signature verification failed: {e}" - )), - } - }, - Err(e) => RoomMessageEventContent::text_plain(format!("Invalid json: {e}")), - } - } else { - RoomMessageEventContent::text_plain( - "Expected code block in command body. Add --help for details.", - ) - } - }, - }, - AdminCommand::Server(command) => match command { - ServerCommand::ShowConfig => { - // Construct and send the response - RoomMessageEventContent::text_plain(format!("{}", services().globals.config)) - }, - ServerCommand::MemoryUsage => { - let response1 = services().memory_usage().await; - let response2 = services().globals.db.memory_usage(); - - RoomMessageEventContent::text_plain(format!("Services:\n{response1}\n\nDatabase:\n{response2}")) - }, - ServerCommand::ClearDatabaseCaches { - amount, - } => { - services().globals.db.clear_caches(amount); - - RoomMessageEventContent::text_plain("Done.") - }, - ServerCommand::ClearServiceCaches { - amount, - } => { - services().clear_caches(amount).await; - - RoomMessageEventContent::text_plain("Done.") - }, - ServerCommand::ListBackups => { - let result = services().globals.db.backup_list()?; - - if result.is_empty() { - return Ok(RoomMessageEventContent::text_plain("No backups found.")); - } - - RoomMessageEventContent::text_plain(result) - }, - ServerCommand::BackupDatabase => { - if !cfg!(feature = "rocksdb") { - return Ok(RoomMessageEventContent::text_plain( - "Only RocksDB supports online backups in conduwuit.", - )); - } - - let mut result = tokio::task::spawn_blocking(move || match services().globals.db.backup() { - Ok(()) => String::new(), - Err(e) => (*e).to_string(), - }) - .await - .unwrap(); - - if result.is_empty() { - result = services().globals.db.backup_list()?; - } - - RoomMessageEventContent::text_plain(&result) - }, - }, - AdminCommand::Debug(command) => match command { - DebugCommand::GetAuthChain { - event_id, - } => { - let event_id = Arc::::from(event_id); - if let Some(event) = services().rooms.timeline.get_pdu_json(&event_id)? { - let room_id_str = event - .get("room_id") - .and_then(|val| val.as_str()) - .ok_or_else(|| Error::bad_database("Invalid event in database"))?; - - let room_id = <&RoomId>::try_from(room_id_str) - .map_err(|_| Error::bad_database("Invalid room id field in event in database"))?; - let start = Instant::now(); - let count = services().rooms.auth_chain.get_auth_chain(room_id, vec![event_id]).await?.count(); - let elapsed = start.elapsed(); - RoomMessageEventContent::text_plain(format!( - "Loaded auth chain with length {count} in {elapsed:?}" - )) - } else { - RoomMessageEventContent::text_plain("Event not found.") - } - }, - DebugCommand::ParsePdu => { - if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" { - let string = body[1..body.len() - 1].join("\n"); - match serde_json::from_str(&string) { - Ok(value) => match ruma::signatures::reference_hash(&value, &RoomVersionId::V6) { - Ok(hash) => { - let event_id = EventId::parse(format!("${hash}")); - - match serde_json::from_value::( - serde_json::to_value(value).expect("value is json"), - ) { - Ok(pdu) => RoomMessageEventContent::text_plain(format!( - "EventId: {event_id:?}\n{pdu:#?}" - )), - Err(e) => RoomMessageEventContent::text_plain(format!( - "EventId: {event_id:?}\nCould not parse event: {e}" - )), - } - }, - Err(e) => { - RoomMessageEventContent::text_plain(format!("Could not parse PDU JSON: {e:?}")) - }, - }, - Err(e) => RoomMessageEventContent::text_plain(format!("Invalid json in command body: {e}")), - } - } else { - RoomMessageEventContent::text_plain("Expected code block in command body.") - } - }, - DebugCommand::GetPdu { - event_id, - } => { - let mut outlier = false; - let mut pdu_json = services().rooms.timeline.get_non_outlier_pdu_json(&event_id)?; - if pdu_json.is_none() { - outlier = true; - pdu_json = services().rooms.timeline.get_pdu_json(&event_id)?; - } - match pdu_json { - Some(json) => { - let json_text = serde_json::to_string_pretty(&json).expect("canonical json is valid json"); - return Ok(RoomMessageEventContent::text_html( - format!( - "{}\n```json\n{}\n```", - if outlier { - "Outlier PDU found in our database" - } else { - "PDU found in our database" - }, - json_text - ), - format!( - "

    {}

    \n
    {}\n
    \n", - if outlier { - "Outlier PDU found in our database" - } else { - "PDU found in our database" - }, - HtmlEscape(&json_text) - ), - )); - }, - None => { - return Ok(RoomMessageEventContent::text_plain("PDU not found locally.")); - }, - } - }, - DebugCommand::GetRemotePdu { - event_id, - server, - } => { - if !services().globals.config.allow_federation { - return Ok(RoomMessageEventContent::text_plain( - "Federation is disabled on this homeserver.", - )); - } - - if server == services().globals.server_name() { - return Ok(RoomMessageEventContent::text_plain( - "Not allowed to send federation requests to ourselves. Please use `get-pdu` for fetching \ - local PDUs.", - )); - } - - // TODO: use Futures as some requests may take a while so we dont block the - // admin room - match services() - .sending - .send_federation_request( - &server, - ruma::api::federation::event::get_event::v1::Request { - event_id: event_id.clone().into(), - }, - ) - .await - { - Ok(response) => { - let json: CanonicalJsonObject = serde_json::from_str(response.pdu.get()).map_err(|e| { - warn!( - "Requested event ID {event_id} from server but failed to convert from RawValue to \ - CanonicalJsonObject (malformed event/response?): {e}" - ); - Error::BadRequest( - ErrorKind::Unknown, - "Received response from server but failed to parse PDU", - ) - })?; - - debug!("Attempting to parse PDU: {:?}", &response.pdu); - let parsed_pdu = { - let parsed_result = parse_incoming_pdu(&response.pdu); - let (event_id, value, room_id) = match parsed_result { - Ok(t) => t, - Err(e) => { - warn!("Failed to parse PDU: {e}"); - info!("Full PDU: {:?}", &response.pdu); - return Ok(RoomMessageEventContent::text_plain(format!( - "Failed to parse PDU remote server {server} sent us: {e}" - ))); - }, - }; - - vec![(event_id, value, room_id)] - }; - - let pub_key_map = RwLock::new(BTreeMap::new()); - - debug!("Attempting to fetch homeserver signing keys for {server}"); - services() - .rooms - .event_handler - .fetch_required_signing_keys( - parsed_pdu.iter().map(|(_event_id, event, _room_id)| event), - &pub_key_map, - ) - .await - .unwrap_or_else(|e| { - warn!("Could not fetch all signatures for PDUs from {server}: {e:?}"); - }); - - info!("Attempting to handle event ID {event_id} as backfilled PDU"); - services().rooms.timeline.backfill_pdu(&server, response.pdu, &pub_key_map).await?; - - let json_text = serde_json::to_string_pretty(&json).expect("canonical json is valid json"); - - return Ok(RoomMessageEventContent::text_html( - format!( - "{}\n```json\n{}\n```", - "Got PDU from specified server and handled as backfilled PDU successfully. Event \ - body:", - json_text - ), - format!( - "

    {}

    \n
    {}\n
    \n", - "Got PDU from specified server and handled as backfilled PDU successfully. Event \ - body:", - HtmlEscape(&json_text) - ), - )); - }, - Err(_) => { - return Ok(RoomMessageEventContent::text_plain( - "Remote server did not have PDU or failed sending request to remote server.", - )); - }, - } - }, - DebugCommand::GetRoomState { - room_id, - } => { - let room_state = services() - .rooms - .state_accessor - .room_state_full(&room_id) - .await? - .values() - .map(|pdu| pdu.to_state_event()) - .collect::>(); - - if room_state.is_empty() { - return Ok(RoomMessageEventContent::text_plain( - "Unable to find room state in our database (vector is empty)", - )); - } - - let json_text = serde_json::to_string_pretty(&room_state).map_err(|e| { - error!("Failed converting room state vector in our database to pretty JSON: {e}"); - Error::bad_database( - "Failed to convert room state events to pretty JSON, possible invalid room state events \ - in our database", - ) - })?; - - return Ok(RoomMessageEventContent::text_html( - format!("{}\n```json\n{}\n```", "Found full room state", json_text), - format!( - "

    {}

    \n
    {}\n
    \n", - "Found full room state", - HtmlEscape(&json_text) - ), - )); - }, - DebugCommand::ForceDeviceListUpdates => { - // Force E2EE device list updates for all users - for user_id in services().users.iter().filter_map(Result::ok) { - services().users.mark_device_key_update(&user_id)?; - } - RoomMessageEventContent::text_plain("Marked all devices for all users as having new keys to update") - }, - }, + AdminCommand::Appservices(command) => appservice::process(command, body).await.unwrap(), + AdminCommand::Media(command) => media::process(command, body).await.unwrap(), + AdminCommand::Users(command) => user::process(command, body).await.unwrap(), + AdminCommand::Rooms(command) => room::process(command, body).await.unwrap(), + AdminCommand::Federation(command) => federation::process(command, body).await.unwrap(), + AdminCommand::Server(command) => server::process(command, body).await.unwrap(), + AdminCommand::Debug(command) => debug::process(command, body).await.unwrap(), }; Ok(reply_message_content) } - fn get_room_info(id: &OwnedRoomId) -> (OwnedRoomId, u64, String) { - ( - id.clone(), - services().rooms.state_cache.room_joined_count(id).ok().flatten().unwrap_or(0), - services().rooms.state_accessor.get_name(id).ok().flatten().unwrap_or_else(|| id.to_string()), - ) - } - // Utility to turn clap's `--help` text to HTML. fn usage_to_html(&self, text: &str, server_name: &ServerName) -> String { // Replace `@conduit:servername:-subcmdname` with `@conduit:servername: @@ -2615,6 +683,14 @@ impl Service { fn escape_html(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">") } +fn get_room_info(id: &OwnedRoomId) -> (OwnedRoomId, u64, String) { + ( + id.clone(), + services().rooms.state_cache.room_joined_count(id).ok().flatten().unwrap_or(0), + services().rooms.state_accessor.get_name(id).ok().flatten().unwrap_or_else(|| id.to_string()), + ) +} + #[cfg(test)] mod test { use super::*; diff --git a/src/service/admin/room.rs b/src/service/admin/room.rs new file mode 100644 index 000000000..040bc0f24 --- /dev/null +++ b/src/service/admin/room.rs @@ -0,0 +1,90 @@ +use std::fmt::Write as _; + +use clap::Subcommand; +use ruma::{events::room::message::RoomMessageEventContent, OwnedRoomId}; + +use crate::{ + service::admin::{ + escape_html, get_room_info, room_alias, room_alias::RoomAliasCommand, room_directory, + room_directory::RoomDirectoryCommand, room_moderation, room_moderation::RoomModerationCommand, PAGE_SIZE, + }, + services, Result, +}; + +#[cfg_attr(test, derive(Debug))] +#[derive(Subcommand)] +pub(crate) enum RoomCommand { + /// - List all rooms the server knows about + List { + page: Option, + }, + + #[command(subcommand)] + /// - Manage moderation of remote or local rooms + Moderation(RoomModerationCommand), + + #[command(subcommand)] + /// - Manage rooms' aliases + Alias(RoomAliasCommand), + + #[command(subcommand)] + /// - Manage the room directory + Directory(RoomDirectoryCommand), +} + +pub(crate) async fn process(command: RoomCommand, body: Vec<&str>) -> Result { + match command { + RoomCommand::Alias(command) => room_alias::process(command, body).await, + + RoomCommand::Directory(command) => room_directory::process(command, body).await, + + RoomCommand::Moderation(command) => room_moderation::process(command, body).await, + + RoomCommand::List { + page, + } => { + // TODO: i know there's a way to do this with clap, but i can't seem to find it + let page = page.unwrap_or(1); + let mut rooms = services() + .rooms + .metadata + .iter_ids() + .filter_map(Result::ok) + .map(|id: OwnedRoomId| get_room_info(&id)) + .collect::>(); + rooms.sort_by_key(|r| r.1); + rooms.reverse(); + + let rooms = rooms.into_iter().skip(page.saturating_sub(1) * PAGE_SIZE).take(PAGE_SIZE).collect::>(); + + if rooms.is_empty() { + return Ok(RoomMessageEventContent::text_plain("No more rooms.")); + }; + + let output_plain = format!( + "Rooms:\n{}", + rooms + .iter() + .map(|(id, members, name)| format!("{id}\tMembers: {members}\tName: {name}")) + .collect::>() + .join("\n") + ); + let output_html = format!( + "\n\t\t\n{}
    Room list - page \ + {page}
    idmembersname
    ", + rooms.iter().fold(String::new(), |mut output, (id, members, name)| { + writeln!( + output, + "{}\t{}\t{}", + escape_html(id.as_ref()), + members, + escape_html(name) + ) + .unwrap(); + output + }) + ); + Ok(RoomMessageEventContent::text_html(output_plain, output_html)) + }, + } +} diff --git a/src/service/admin/room_alias.rs b/src/service/admin/room_alias.rs new file mode 100644 index 000000000..14ca078d6 --- /dev/null +++ b/src/service/admin/room_alias.rs @@ -0,0 +1,160 @@ +use std::fmt::Write as _; + +use clap::Subcommand; +use ruma::{events::room::message::RoomMessageEventContent, RoomAliasId, RoomId}; + +use crate::{service::admin::escape_html, services, Result}; + +#[cfg_attr(test, derive(Debug))] +#[derive(Subcommand)] +pub(crate) enum RoomAliasCommand { + /// - Make an alias point to a room. + Set { + #[arg(short, long)] + /// Set the alias even if a room is already using it + force: bool, + + /// The room id to set the alias on + room_id: Box, + + /// The alias localpart to use (`alias`, not `#alias:servername.tld`) + room_alias_localpart: String, + }, + + /// - Remove an alias + Remove { + /// The alias localpart to remove (`alias`, not `#alias:servername.tld`) + room_alias_localpart: String, + }, + + /// - Show which room is using an alias + Which { + /// The alias localpart to look up (`alias`, not + /// `#alias:servername.tld`) + room_alias_localpart: String, + }, + + /// - List aliases currently being used + List { + /// If set, only list the aliases for this room + room_id: Option>, + }, +} + +pub(crate) async fn process(command: RoomAliasCommand, _body: Vec<&str>) -> Result { + match command { + RoomAliasCommand::Set { + ref room_alias_localpart, + .. + } + | RoomAliasCommand::Remove { + ref room_alias_localpart, + } + | RoomAliasCommand::Which { + ref room_alias_localpart, + } => { + let room_alias_str = format!("#{}:{}", room_alias_localpart, services().globals.server_name()); + let room_alias = match RoomAliasId::parse_box(room_alias_str) { + Ok(alias) => alias, + Err(err) => return Ok(RoomMessageEventContent::text_plain(format!("Failed to parse alias: {}", err))), + }; + match command { + RoomAliasCommand::Set { + force, + room_id, + .. + } => match (force, services().rooms.alias.resolve_local_alias(&room_alias)) { + (true, Ok(Some(id))) => match services().rooms.alias.set_alias(&room_alias, &room_id) { + Ok(()) => Ok(RoomMessageEventContent::text_plain(format!( + "Successfully overwrote alias (formerly {})", + id + ))), + Err(err) => Ok(RoomMessageEventContent::text_plain(format!("Failed to remove alias: {}", err))), + }, + (false, Ok(Some(id))) => Ok(RoomMessageEventContent::text_plain(format!( + "Refusing to overwrite in use alias for {}, use -f or --force to overwrite", + id + ))), + (_, Ok(None)) => match services().rooms.alias.set_alias(&room_alias, &room_id) { + Ok(()) => Ok(RoomMessageEventContent::text_plain("Successfully set alias")), + Err(err) => Ok(RoomMessageEventContent::text_plain(format!("Failed to remove alias: {err}"))), + }, + (_, Err(err)) => Ok(RoomMessageEventContent::text_plain(format!("Unable to lookup alias: {err}"))), + }, + RoomAliasCommand::Remove { + .. + } => match services().rooms.alias.resolve_local_alias(&room_alias) { + Ok(Some(id)) => match services().rooms.alias.remove_alias(&room_alias) { + Ok(()) => Ok(RoomMessageEventContent::text_plain(format!("Removed alias from {}", id))), + Err(err) => Ok(RoomMessageEventContent::text_plain(format!("Failed to remove alias: {}", err))), + }, + Ok(None) => Ok(RoomMessageEventContent::text_plain("Alias isn't in use.")), + Err(err) => Ok(RoomMessageEventContent::text_plain(format!("Unable to lookup alias: {}", err))), + }, + RoomAliasCommand::Which { + .. + } => match services().rooms.alias.resolve_local_alias(&room_alias) { + Ok(Some(id)) => Ok(RoomMessageEventContent::text_plain(format!("Alias resolves to {}", id))), + Ok(None) => Ok(RoomMessageEventContent::text_plain("Alias isn't in use.")), + Err(err) => Ok(RoomMessageEventContent::text_plain(format!("Unable to lookup alias: {}", err))), + }, + RoomAliasCommand::List { + .. + } => unreachable!(), + } + }, + RoomAliasCommand::List { + room_id, + } => { + if let Some(room_id) = room_id { + let aliases = services().rooms.alias.local_aliases_for_room(&room_id).collect::, _>>(); + match aliases { + Ok(aliases) => { + let plain_list = aliases.iter().fold(String::new(), |mut output, alias| { + writeln!(output, "- {alias}").unwrap(); + output + }); + + let html_list = aliases.iter().fold(String::new(), |mut output, alias| { + writeln!(output, "
  • {}
  • ", escape_html(alias.as_ref())).unwrap(); + output + }); + + let plain = format!("Aliases for {room_id}:\n{plain_list}"); + let html = format!("Aliases for {room_id}:\n
      {html_list}
    "); + Ok(RoomMessageEventContent::text_html(plain, html)) + }, + Err(err) => Ok(RoomMessageEventContent::text_plain(format!("Unable to list aliases: {}", err))), + } + } else { + let aliases = services().rooms.alias.all_local_aliases().collect::, _>>(); + match aliases { + Ok(aliases) => { + let server_name = services().globals.server_name(); + let plain_list = aliases.iter().fold(String::new(), |mut output, (alias, id)| { + writeln!(output, "- `{alias}` -> #{id}:{server_name}").unwrap(); + output + }); + + let html_list = aliases.iter().fold(String::new(), |mut output, (alias, id)| { + writeln!( + output, + "
  • {} -> #{}:{}
  • ", + escape_html(alias.as_ref()), + escape_html(id.as_ref()), + server_name + ) + .unwrap(); + output + }); + + let plain = format!("Aliases:\n{plain_list}"); + let html = format!("Aliases:\n
      {html_list}
    "); + Ok(RoomMessageEventContent::text_html(plain, html)) + }, + Err(e) => Ok(RoomMessageEventContent::text_plain(format!("Unable to list room aliases: {e}"))), + } + } + }, + } +} diff --git a/src/service/admin/room_directory.rs b/src/service/admin/room_directory.rs new file mode 100644 index 000000000..d0fdcf6a6 --- /dev/null +++ b/src/service/admin/room_directory.rs @@ -0,0 +1,93 @@ +use std::fmt::Write as _; + +use clap::Subcommand; +use ruma::{events::room::message::RoomMessageEventContent, OwnedRoomId, RoomId}; + +use crate::{ + service::admin::{escape_html, get_room_info, PAGE_SIZE}, + services, Result, +}; + +#[cfg_attr(test, derive(Debug))] +#[derive(Subcommand)] +pub(crate) enum RoomDirectoryCommand { + /// - Publish a room to the room directory + Publish { + /// The room id of the room to publish + room_id: Box, + }, + + /// - Unpublish a room to the room directory + Unpublish { + /// The room id of the room to unpublish + room_id: Box, + }, + + /// - List rooms that are published + List { + page: Option, + }, +} + +pub(crate) async fn process(command: RoomDirectoryCommand, _body: Vec<&str>) -> Result { + match command { + RoomDirectoryCommand::Publish { + room_id, + } => match services().rooms.directory.set_public(&room_id) { + Ok(()) => Ok(RoomMessageEventContent::text_plain("Room published")), + Err(err) => Ok(RoomMessageEventContent::text_plain(format!("Unable to update room: {}", err))), + }, + RoomDirectoryCommand::Unpublish { + room_id, + } => match services().rooms.directory.set_not_public(&room_id) { + Ok(()) => Ok(RoomMessageEventContent::text_plain("Room unpublished")), + Err(err) => Ok(RoomMessageEventContent::text_plain(format!("Unable to update room: {}", err))), + }, + RoomDirectoryCommand::List { + page, + } => { + // TODO: i know there's a way to do this with clap, but i can't seem to find it + let page = page.unwrap_or(1); + let mut rooms = services() + .rooms + .directory + .public_rooms() + .filter_map(Result::ok) + .map(|id: OwnedRoomId| get_room_info(&id)) + .collect::>(); + rooms.sort_by_key(|r| r.1); + rooms.reverse(); + + let rooms = rooms.into_iter().skip(page.saturating_sub(1) * PAGE_SIZE).take(PAGE_SIZE).collect::>(); + + if rooms.is_empty() { + return Ok(RoomMessageEventContent::text_plain("No more rooms.")); + }; + + let output_plain = format!( + "Rooms:\n{}", + rooms + .iter() + .map(|(id, members, name)| format!("{id}\tMembers: {members}\tName: {name}")) + .collect::>() + .join("\n") + ); + let output_html = format!( + "\n\t\t\n{}
    Room directory - page \ + {page}
    idmembersname
    ", + rooms.iter().fold(String::new(), |mut output, (id, members, name)| { + writeln!( + output, + "{}\t{}\t{}", + escape_html(id.as_ref()), + members, + escape_html(name.as_ref()) + ) + .unwrap(); + output + }) + ); + Ok(RoomMessageEventContent::text_html(output_plain, output_html)) + }, + } +} diff --git a/src/service/admin/room_moderation.rs b/src/service/admin/room_moderation.rs new file mode 100644 index 000000000..ca20ca206 --- /dev/null +++ b/src/service/admin/room_moderation.rs @@ -0,0 +1,480 @@ +use std::fmt::Write as _; + +use clap::Subcommand; +use ruma::{events::room::message::RoomMessageEventContent, OwnedUserId, RoomAliasId, RoomId, RoomOrAliasId}; +use tracing::{debug, error, info}; + +use crate::{ + api::client_server::{get_alias_helper, leave_room}, + service::admin::{escape_html, Service}, + services, Result, +}; + +#[cfg_attr(test, derive(Debug))] +#[derive(Subcommand)] +pub(crate) enum RoomModerationCommand { + /// - Bans a room from local users joining and evicts all our local users + /// from the room. Also blocks any invites (local and remote) for the + /// banned room. + /// + /// Server admins (users in the conduwuit admin room) will not be evicted + /// and server admins can still join the room. To evict admins too, use + /// --force (also ignores errors) To disable incoming federation of the + /// room, use --disable-federation + BanRoom { + #[arg(short, long)] + /// Evicts admins out of the room and ignores any potential errors when + /// making our local users leave the room + force: bool, + + #[arg(long)] + /// Disables incoming federation of the room after banning and evicting + /// users + disable_federation: bool, + + /// The room in the format of `!roomid:example.com` or a room alias in + /// the format of `#roomalias:example.com` + room: Box, + }, + + /// - Bans a list of rooms from a newline delimited codeblock similar to + /// `user deactivate-all` + BanListOfRooms { + #[arg(short, long)] + /// Evicts admins out of the room and ignores any potential errors when + /// making our local users leave the room + force: bool, + + #[arg(long)] + /// Disables incoming federation of the room after banning and evicting + /// users + disable_federation: bool, + }, + + /// - Unbans a room to allow local users to join again + /// + /// To re-enable incoming federation of the room, use --enable-federation + UnbanRoom { + #[arg(long)] + /// Enables incoming federation of the room after unbanning + enable_federation: bool, + + /// The room in the format of `!roomid:example.com` or a room alias in + /// the format of `#roomalias:example.com` + room: Box, + }, + + /// - List of all rooms we have banned + ListBannedRooms, +} + +pub(crate) async fn process(command: RoomModerationCommand, body: Vec<&str>) -> Result { + match command { + RoomModerationCommand::BanRoom { + force, + room, + disable_federation, + } => { + debug!("Got room alias or ID: {}", room); + + let admin_room_alias: Box = format!("#admins:{}", services().globals.server_name()) + .try_into() + .expect("#admins:server_name is a valid alias name"); + + if let Some(admin_room_id) = Service::get_admin_room()? { + if room.to_string().eq(&admin_room_id) || room.to_string().eq(&admin_room_alias) { + return Ok(RoomMessageEventContent::text_plain("Not allowed to ban the admin room.")); + } + } + + let room_id = if room.is_room_id() { + let room_id = match RoomId::parse(&room) { + Ok(room_id) => room_id, + Err(e) => { + return Ok(RoomMessageEventContent::text_plain(format!( + "Failed to parse room ID {room}. Please note that this requires a full room ID \ + (`!awIh6gGInaS5wLQJwa:example.com`) or a room alias (`#roomalias:example.com`): {e}" + ))) + }, + }; + + debug!("Room specified is a room ID, banning room ID"); + + services().rooms.metadata.ban_room(&room_id, true)?; + + room_id + } else if room.is_room_alias_id() { + let room_alias = match RoomAliasId::parse(&room) { + Ok(room_alias) => room_alias, + Err(e) => { + return Ok(RoomMessageEventContent::text_plain(format!( + "Failed to parse room ID {room}. Please note that this requires a full room ID \ + (`!awIh6gGInaS5wLQJwa:example.com`) or a room alias (`#roomalias:example.com`): {e}" + ))) + }, + }; + + debug!( + "Room specified is not a room ID, attempting to resolve room alias to a room ID locally, if not \ + using get_alias_helper to fetch room ID remotely" + ); + + let room_id = match services().rooms.alias.resolve_local_alias(&room_alias)? { + Some(room_id) => room_id, + None => { + debug!( + "We don't have this room alias to a room ID locally, attempting to fetch room ID over \ + federation" + ); + + match get_alias_helper(room_alias).await { + Ok(response) => { + debug!("Got federation response fetching room ID for room {room}: {:?}", response); + response.room_id + }, + Err(e) => { + return Ok(RoomMessageEventContent::text_plain(format!( + "Failed to resolve room alias {room} to a room ID: {e}" + ))); + }, + } + }, + }; + + services().rooms.metadata.ban_room(&room_id, true)?; + + room_id + } else { + return Ok(RoomMessageEventContent::text_plain( + "Room specified is not a room ID or room alias. Please note that this requires a full room ID \ + (`!awIh6gGInaS5wLQJwa:example.com`) or a room alias (`#roomalias:example.com`)", + )); + }; + + debug!("Making all users leave the room {}", &room); + if force { + for local_user in services() + .rooms + .state_cache + .room_members(&room_id) + .filter_map(|user| { + user.ok().filter(|local_user| { + local_user.server_name() == services().globals.server_name() + // additional wrapped check here is to avoid adding remote users + // who are in the admin room to the list of local users (would fail auth check) + && (local_user.server_name() + == services().globals.server_name() + && services() + .users + .is_admin(local_user) + .unwrap_or(true)) // since this is a force + // operation, assume user + // is an admin if somehow + // this fails + }) + }) + .collect::>() + { + debug!( + "Attempting leave for user {} in room {} (forced, ignoring all errors, evicting admins too)", + &local_user, &room_id + ); + + _ = leave_room(&local_user, &room_id, None).await; + } + } else { + for local_user in services() + .rooms + .state_cache + .room_members(&room_id) + .filter_map(|user| { + user.ok().filter(|local_user| { + local_user.server_name() == services().globals.server_name() + // additional wrapped check here is to avoid adding remote users + // who are in the admin room to the list of local users (would fail auth check) + && (local_user.server_name() + == services().globals.server_name() + && !services() + .users + .is_admin(local_user) + .unwrap_or(false)) + }) + }) + .collect::>() + { + debug!("Attempting leave for user {} in room {}", &local_user, &room_id); + if let Err(e) = leave_room(&local_user, &room_id, None).await { + error!( + "Error attempting to make local user {} leave room {} during room banning: {}", + &local_user, &room_id, e + ); + return Ok(RoomMessageEventContent::text_plain(format!( + "Error attempting to make local user {} leave room {} during room banning (room is still \ + banned but not removing any more users): {}\nIf you would like to ignore errors, use \ + --force", + &local_user, &room_id, e + ))); + } + } + } + + if disable_federation { + services().rooms.metadata.disable_room(&room_id, true)?; + return Ok(RoomMessageEventContent::text_plain( + "Room banned, removed all our local users, and disabled incoming federation with room.", + )); + } + + Ok(RoomMessageEventContent::text_plain( + "Room banned and removed all our local users, use disable-room to stop receiving new inbound \ + federation events as well if needed.", + )) + }, + RoomModerationCommand::BanListOfRooms { + force, + disable_federation, + } => { + if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" { + let rooms_s = body.clone().drain(1..body.len() - 1).collect::>(); + + let mut room_ban_count = 0; + let mut room_ids: Vec<&RoomId> = Vec::new(); + + for &room_id in &rooms_s { + match <&RoomId>::try_from(room_id) { + Ok(owned_room_id) => { + // silently ignore deleting admin room + if let Some(admin_room_id) = Service::get_admin_room()? { + if owned_room_id.eq(&admin_room_id) { + info!("User specified admin room in bulk ban list, ignoring"); + continue; + } + } + + room_ids.push(owned_room_id); + }, + Err(e) => { + if force { + // ignore rooms we failed to parse if we're force deleting + error!( + "Error parsing room ID {room_id} during bulk room banning, ignoring error and \ + logging here: {e}" + ); + continue; + } + + return Ok(RoomMessageEventContent::text_plain(format!( + "{room_id} is not a valid room ID, please fix the list and try again: {e}" + ))); + }, + } + } + + for room_id in room_ids { + if services().rooms.metadata.ban_room(room_id, true).is_ok() { + debug!("Banned {room_id} successfully"); + room_ban_count += 1; + } + + debug!("Making all users leave the room {}", &room_id); + if force { + for local_user in services() + .rooms + .state_cache + .room_members(room_id) + .filter_map(|user| { + user.ok().filter(|local_user| { + local_user.server_name() == services().globals.server_name() + // additional wrapped check here is to avoid adding remote users + // who are in the admin room to the list of local users (would fail auth check) + && (local_user.server_name() + == services().globals.server_name() + && services() + .users + .is_admin(local_user) + .unwrap_or(true)) // since this is a + // force operation, + // assume user is + // an admin if + // somehow this + // fails + }) + }) + .collect::>() + { + debug!( + "Attempting leave for user {} in room {} (forced, ignoring all errors, evicting \ + admins too)", + &local_user, room_id + ); + _ = leave_room(&local_user, room_id, None).await; + } + } else { + for local_user in services() + .rooms + .state_cache + .room_members(room_id) + .filter_map(|user| { + user.ok().filter(|local_user| { + local_user.server_name() == services().globals.server_name() + // additional wrapped check here is to avoid adding remote users + // who are in the admin room to the list of local users (would fail auth check) + && (local_user.server_name() + == services().globals.server_name() + && !services() + .users + .is_admin(local_user) + .unwrap_or(false)) + }) + }) + .collect::>() + { + debug!("Attempting leave for user {} in room {}", &local_user, &room_id); + if let Err(e) = leave_room(&local_user, room_id, None).await { + error!( + "Error attempting to make local user {} leave room {} during bulk room banning: {}", + &local_user, &room_id, e + ); + return Ok(RoomMessageEventContent::text_plain(format!( + "Error attempting to make local user {} leave room {} during room banning (room \ + is still banned but not removing any more users and not banning any more rooms): \ + {}\nIf you would like to ignore errors, use --force", + &local_user, &room_id, e + ))); + } + } + } + + if disable_federation { + services().rooms.metadata.disable_room(room_id, true)?; + } + } + + if disable_federation { + return Ok(RoomMessageEventContent::text_plain(format!( + "Finished bulk room ban, banned {} total rooms, evicted all users, and disabled incoming \ + federation with the room.", + room_ban_count + ))); + } + return Ok(RoomMessageEventContent::text_plain(format!( + "Finished bulk room ban, banned {} total rooms and evicted all users.", + room_ban_count + ))); + } + + Ok(RoomMessageEventContent::text_plain( + "Expected code block in command body. Add --help for details.", + )) + }, + RoomModerationCommand::UnbanRoom { + room, + enable_federation, + } => { + let room_id = if room.is_room_id() { + let room_id = match RoomId::parse(&room) { + Ok(room_id) => room_id, + Err(e) => { + return Ok(RoomMessageEventContent::text_plain(format!( + "Failed to parse room ID {room}. Please note that this requires a full room ID \ + (`!awIh6gGInaS5wLQJwa:example.com`) or a room alias (`#roomalias:example.com`): {e}" + ))) + }, + }; + + debug!("Room specified is a room ID, unbanning room ID"); + + services().rooms.metadata.ban_room(&room_id, false)?; + + room_id + } else if room.is_room_alias_id() { + let room_alias = match RoomAliasId::parse(&room) { + Ok(room_alias) => room_alias, + Err(e) => { + return Ok(RoomMessageEventContent::text_plain(format!( + "Failed to parse room ID {room}. Please note that this requires a full room ID \ + (`!awIh6gGInaS5wLQJwa:example.com`) or a room alias (`#roomalias:example.com`): {e}" + ))) + }, + }; + + debug!( + "Room specified is not a room ID, attempting to resolve room alias to a room ID locally, if not \ + using get_alias_helper to fetch room ID remotely" + ); + + let room_id = match services().rooms.alias.resolve_local_alias(&room_alias)? { + Some(room_id) => room_id, + None => { + debug!( + "We don't have this room alias to a room ID locally, attempting to fetch room ID over \ + federation" + ); + + match get_alias_helper(room_alias).await { + Ok(response) => { + debug!("Got federation response fetching room ID for room {room}: {:?}", response); + response.room_id + }, + Err(e) => { + return Ok(RoomMessageEventContent::text_plain(format!( + "Failed to resolve room alias {room} to a room ID: {e}" + ))); + }, + } + }, + }; + + services().rooms.metadata.ban_room(&room_id, false)?; + + room_id + } else { + return Ok(RoomMessageEventContent::text_plain( + "Room specified is not a room ID or room alias. Please note that this requires a full room ID \ + (`!awIh6gGInaS5wLQJwa:example.com`) or a room alias (`#roomalias:example.com`)", + )); + }; + + if enable_federation { + services().rooms.metadata.disable_room(&room_id, false)?; + return Ok(RoomMessageEventContent::text_plain("Room unbanned.")); + } + + Ok(RoomMessageEventContent::text_plain( + "Room unbanned, you may need to re-enable federation with the room using enable-room if this is a \ + remote room to make it fully functional.", + )) + }, + RoomModerationCommand::ListBannedRooms => { + let rooms = services().rooms.metadata.list_banned_rooms().collect::, _>>(); + + match rooms { + Ok(room_ids) => { + // TODO: add room name from our state cache if available, default to the room ID + // as the room name if we dont have it TODO: do same if we have a room alias for + // this + let plain_list = room_ids.iter().fold(String::new(), |mut output, room_id| { + writeln!(output, "- `{}`", room_id).unwrap(); + output + }); + + let html_list = room_ids.iter().fold(String::new(), |mut output, room_id| { + writeln!(output, "
  • {}
  • ", escape_html(room_id.as_ref())).unwrap(); + output + }); + + let plain = format!("Rooms:\n{}", plain_list); + let html = format!("Rooms:\n
      {}
    ", html_list); + Ok(RoomMessageEventContent::text_html(plain, html)) + }, + Err(e) => { + error!("Failed to list banned rooms: {}", e); + Ok(RoomMessageEventContent::text_plain(format!( + "Unable to list room aliases: {}", + e + ))) + }, + } + }, + } +} diff --git a/src/service/admin/server.rs b/src/service/admin/server.rs new file mode 100644 index 000000000..07519a1bc --- /dev/null +++ b/src/service/admin/server.rs @@ -0,0 +1,106 @@ +use clap::Subcommand; +use ruma::events::room::message::RoomMessageEventContent; + +use crate::{services, Result}; + +#[cfg_attr(test, derive(Debug))] +#[derive(Subcommand)] +pub(crate) enum ServerCommand { + /// - Show configuration values + ShowConfig, + + /// - Print database memory usage statistics + MemoryUsage, + + /// - Clears all of Conduit's database caches with index smaller than the + /// amount + ClearDatabaseCaches { + amount: u32, + }, + + /// - Clears all of Conduit's service caches with index smaller than the + /// amount + ClearServiceCaches { + amount: u32, + }, + + /// - Performs an online backup of the database (only available for RocksDB + /// at the moment) + BackupDatabase, + + /// - List database backups + ListBackups, + + /// - List database files + ListDatabaseFiles, +} + +pub(crate) async fn process(command: ServerCommand, _body: Vec<&str>) -> Result { + match command { + ServerCommand::ShowConfig => { + // Construct and send the response + Ok(RoomMessageEventContent::text_plain(format!("{}", services().globals.config))) + }, + ServerCommand::MemoryUsage => { + let response1 = services().memory_usage().await; + let response2 = services().globals.db.memory_usage(); + + Ok(RoomMessageEventContent::text_plain(format!( + "Services:\n{response1}\n\nDatabase:\n{response2}" + ))) + }, + ServerCommand::ClearDatabaseCaches { + amount, + } => { + services().globals.db.clear_caches(amount); + + Ok(RoomMessageEventContent::text_plain("Done.")) + }, + ServerCommand::ClearServiceCaches { + amount, + } => { + services().clear_caches(amount).await; + + Ok(RoomMessageEventContent::text_plain("Done.")) + }, + ServerCommand::ListBackups => { + let result = services().globals.db.backup_list()?; + + if result.is_empty() { + Ok(RoomMessageEventContent::text_plain("No backups found.")) + } else { + Ok(RoomMessageEventContent::text_plain(result)) + } + }, + ServerCommand::BackupDatabase => { + if !cfg!(feature = "rocksdb") { + return Ok(RoomMessageEventContent::text_plain( + "Only RocksDB supports online backups in conduwuit.", + )); + } + + let mut result = tokio::task::spawn_blocking(move || match services().globals.db.backup() { + Ok(()) => String::new(), + Err(e) => (*e).to_string(), + }) + .await + .unwrap(); + + if result.is_empty() { + result = services().globals.db.backup_list()?; + } + + Ok(RoomMessageEventContent::text_plain(&result)) + }, + ServerCommand::ListDatabaseFiles => { + if !cfg!(feature = "rocksdb") { + return Ok(RoomMessageEventContent::text_plain( + "Only RocksDB supports listing files in conduwuit.", + )); + } + + let result = services().globals.db.file_list()?; + Ok(RoomMessageEventContent::notice_html(String::new(), result)) + }, + } +} diff --git a/src/service/admin/user.rs b/src/service/admin/user.rs new file mode 100644 index 000000000..977e80e44 --- /dev/null +++ b/src/service/admin/user.rs @@ -0,0 +1,361 @@ +use std::{fmt::Write as _, sync::Arc}; + +use clap::Subcommand; +use ruma::{events::room::message::RoomMessageEventContent, UserId}; +use tracing::{error, info, warn}; + +use crate::{ + api::client_server::{join_room_by_id_helper, leave_all_rooms, AUTO_GEN_PASSWORD_LENGTH}, + service::admin::{escape_html, get_room_info}, + services, utils, Result, +}; + +#[cfg_attr(test, derive(Debug))] +#[derive(Subcommand)] +pub(crate) enum UserCommand { + /// - Create a new user + Create { + /// Username of the new user + username: String, + /// Password of the new user, if unspecified one is generated + password: Option, + }, + + /// - Reset user password + ResetPassword { + /// Username of the user for whom the password should be reset + username: String, + }, + + /// - Deactivate a user + /// + /// User will not be removed from all rooms by default. + /// Use --leave-rooms to force the user to leave all rooms + Deactivate { + #[arg(short, long)] + leave_rooms: bool, + user_id: Box, + }, + + /// - Deactivate a list of users + /// + /// Recommended to use in conjunction with list-local-users. + /// + /// Users will not be removed from joined rooms by default. + /// Can be overridden with --leave-rooms flag. + /// Removing a mass amount of users from a room may cause a significant + /// amount of leave events. The time to leave rooms may depend significantly + /// on joined rooms and servers. + /// + /// This command needs a newline separated list of users provided in a + /// Markdown code block below the command. + DeactivateAll { + #[arg(short, long)] + /// Remove users from their joined rooms + leave_rooms: bool, + #[arg(short, long)] + /// Also deactivate admin accounts + force: bool, + }, + + /// - List local users in the database + List, + + /// - Lists all the rooms (local and remote) that the specified user is + /// joined in + ListJoinedRooms { + user_id: Box, + }, +} + +pub(crate) async fn process(command: UserCommand, body: Vec<&str>) -> Result { + match command { + UserCommand::List => match services().users.list_local_users() { + Ok(users) => { + let mut msg = format!("Found {} local user account(s):\n", users.len()); + msg += &users.join("\n"); + Ok(RoomMessageEventContent::text_plain(&msg)) + }, + Err(e) => Ok(RoomMessageEventContent::text_plain(e.to_string())), + }, + UserCommand::Create { + username, + password, + } => { + let password = password.unwrap_or_else(|| utils::random_string(AUTO_GEN_PASSWORD_LENGTH)); + // Validate user id + let user_id = match UserId::parse_with_server_name( + username.as_str().to_lowercase(), + services().globals.server_name(), + ) { + Ok(id) => id, + Err(e) => { + return Ok(RoomMessageEventContent::text_plain(format!( + "The supplied username is not a valid username: {e}" + ))) + }, + }; + if user_id.is_historical() { + return Ok(RoomMessageEventContent::text_plain(format!( + "Userid {user_id} is not allowed due to historical" + ))); + } + if services().users.exists(&user_id)? { + return Ok(RoomMessageEventContent::text_plain(format!("Userid {user_id} already exists"))); + } + // Create user + services().users.create(&user_id, Some(password.as_str()))?; + + // Default to pretty displayname + let mut displayname = user_id.localpart().to_owned(); + + // If `new_user_displayname_suffix` is set, registration will push whatever + // content is set to the user's display name with a space before it + if !services().globals.new_user_displayname_suffix().is_empty() { + displayname.push_str(&(" ".to_owned() + services().globals.new_user_displayname_suffix())); + } + + services().users.set_displayname(&user_id, Some(displayname)).await?; + + // Initial account data + services().account_data.update( + None, + &user_id, + ruma::events::GlobalAccountDataEventType::PushRules.to_string().into(), + &serde_json::to_value(ruma::events::push_rules::PushRulesEvent { + content: ruma::events::push_rules::PushRulesEventContent { + global: ruma::push::Ruleset::server_default(&user_id), + }, + }) + .expect("to json value always works"), + )?; + + if !services().globals.config.auto_join_rooms.is_empty() { + for room in &services().globals.config.auto_join_rooms { + if !services().rooms.state_cache.server_in_room(services().globals.server_name(), room)? { + warn!("Skipping room {room} to automatically join as we have never joined before."); + continue; + } + + if let Some(room_id_server_name) = room.server_name() { + match join_room_by_id_helper( + Some(&user_id), + room, + Some("Automatically joining this room upon registration".to_owned()), + &[room_id_server_name.to_owned(), services().globals.server_name().to_owned()], + None, + ) + .await + { + Ok(_) => { + info!("Automatically joined room {room} for user {user_id}"); + }, + Err(e) => { + // don't return this error so we don't fail registrations + error!("Failed to automatically join room {room} for user {user_id}: {e}"); + }, + }; + } + } + } + + // we dont add a device since we're not the user, just the creator + + // Inhibit login does not work for guests + Ok(RoomMessageEventContent::text_plain(format!( + "Created user with user_id: {user_id} and password: `{password}`" + ))) + }, + UserCommand::Deactivate { + leave_rooms, + user_id, + } => { + let user_id = Arc::::from(user_id); + + // check if user belongs to our server + if user_id.server_name() != services().globals.server_name() { + return Ok(RoomMessageEventContent::text_plain(format!( + "User {user_id} does not belong to our server." + ))); + } + + if services().users.exists(&user_id)? { + RoomMessageEventContent::text_plain(format!("Making {user_id} leave all rooms before deactivation...")); + + services().users.deactivate_account(&user_id)?; + + if leave_rooms { + leave_all_rooms(&user_id).await?; + } + + Ok(RoomMessageEventContent::text_plain(format!( + "User {user_id} has been deactivated" + ))) + } else { + Ok(RoomMessageEventContent::text_plain(format!( + "User {user_id} doesn't exist on this server" + ))) + } + }, + UserCommand::ResetPassword { + username, + } => { + let user_id = match UserId::parse_with_server_name( + username.as_str().to_lowercase(), + services().globals.server_name(), + ) { + Ok(id) => id, + Err(e) => { + return Ok(RoomMessageEventContent::text_plain(format!( + "The supplied username is not a valid username: {e}" + ))) + }, + }; + + // check if user belongs to our server + if user_id.server_name() != services().globals.server_name() { + return Ok(RoomMessageEventContent::text_plain(format!( + "User {user_id} does not belong to our server." + ))); + } + + // Check if the specified user is valid + if !services().users.exists(&user_id)? + || user_id + == UserId::parse_with_server_name("conduit", services().globals.server_name()) + .expect("conduit user exists") + { + return Ok(RoomMessageEventContent::text_plain("The specified user does not exist!")); + } + + let new_password = utils::random_string(AUTO_GEN_PASSWORD_LENGTH); + + match services().users.set_password(&user_id, Some(new_password.as_str())) { + Ok(()) => Ok(RoomMessageEventContent::text_plain(format!( + "Successfully reset the password for user {user_id}: `{new_password}`" + ))), + Err(e) => Ok(RoomMessageEventContent::text_plain(format!( + "Couldn't reset the password for user {user_id}: {e}" + ))), + } + }, + UserCommand::DeactivateAll { + leave_rooms, + force, + } => { + if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" { + let usernames = body.clone().drain(1..body.len() - 1).collect::>(); + + let mut user_ids: Vec<&UserId> = Vec::new(); + + for &username in &usernames { + match <&UserId>::try_from(username) { + Ok(user_id) => user_ids.push(user_id), + Err(e) => { + return Ok(RoomMessageEventContent::text_plain(format!( + "{username} is not a valid username: {e}" + ))) + }, + } + } + + let mut deactivation_count = 0; + let mut admins = Vec::new(); + + if !force { + user_ids.retain(|&user_id| match services().users.is_admin(user_id) { + Ok(is_admin) => { + if is_admin { + admins.push(user_id.localpart()); + false + } else { + true + } + }, + Err(_) => false, + }); + } + + for &user_id in &user_ids { + // check if user belongs to our server and skips over non-local users + if user_id.server_name() != services().globals.server_name() { + continue; + } + + if services().users.deactivate_account(user_id).is_ok() { + deactivation_count += 1; + } + } + + if leave_rooms { + for &user_id in &user_ids { + _ = leave_all_rooms(user_id).await; + } + } + + if admins.is_empty() { + Ok(RoomMessageEventContent::text_plain(format!( + "Deactivated {deactivation_count} accounts." + ))) + } else { + Ok(RoomMessageEventContent::text_plain(format!( + "Deactivated {} accounts.\nSkipped admin accounts: {:?}. Use --force to deactivate admin \ + accounts", + deactivation_count, + admins.join(", ") + ))) + } + } else { + Ok(RoomMessageEventContent::text_plain( + "Expected code block in command body. Add --help for details.", + )) + } + }, + UserCommand::ListJoinedRooms { + user_id, + } => { + if user_id.server_name() != services().globals.server_name() { + return Ok(RoomMessageEventContent::text_plain("User does not belong to our server.")); + } + + let mut rooms = vec![]; // room ID, members joined, room name + + for room_id in services().rooms.state_cache.rooms_joined(&user_id) { + let room_id = room_id?; + rooms.push(get_room_info(&room_id)); + } + + if rooms.is_empty() { + return Ok(RoomMessageEventContent::text_plain("User is not in any rooms.")); + } + + rooms.sort_by_key(|r| r.1); + rooms.reverse(); + + let output_plain = format!( + "Rooms {user_id} Joined:\n{}", + rooms + .iter() + .map(|(id, members, name)| format!("{id}\tMembers: {members}\tName: {name}")) + .collect::>() + .join("\n") + ); + let output_html = format!( + "\n\t\t\n{}
    Rooms {user_id} \ + Joined
    idmembersname
    ", + rooms.iter().fold(String::new(), |mut output, (id, members, name)| { + writeln!( + output, + "{}\t{}\t{}", + escape_html(id.as_ref()), + members, + escape_html(name) + ) + .unwrap(); + output + }) + ); + Ok(RoomMessageEventContent::text_html(output_plain, output_html)) + }, + } +}