use std::{collections::BTreeMap, fmt::Write, sync::Arc, time::Instant}; use clap::Parser; use regex::Regex; use ruma::{ api::appservice::Registration, events::{ push_rules::{PushRulesEvent, PushRulesEventContent}, room::{ canonical_alias::RoomCanonicalAliasEventContent, create::RoomCreateEventContent, guest_access::{GuestAccess, RoomGuestAccessEventContent}, history_visibility::{ HistoryVisibility, RoomHistoryVisibilityEventContent, }, join_rules::{JoinRule, RoomJoinRulesEventContent}, member::{MembershipState, RoomMemberEventContent}, message::RoomMessageEventContent, name::RoomNameEventContent, power_levels::RoomPowerLevelsEventContent, topic::RoomTopicEventContent, }, TimelineEventType, }, signatures::verify_json, EventId, MilliSecondsSinceUnixEpoch, OwnedRoomId, RoomId, RoomVersionId, ServerName, UserId, }; use serde_json::value::to_raw_value; use tokio::sync::{mpsc, Mutex, RwLock}; use tracing::warn; use super::pdu::PduBuilder; use crate::{ api::client_server::{leave_all_rooms, AUTO_GEN_PASSWORD_LENGTH}, services, utils::{self, truncate_str_for_debug}, Error, PduEvent, Result, }; #[derive(Debug, Parser)] #[command(name = "@grapevine:server.name:", version = env!("CARGO_PKG_VERSION"))] enum AdminCommand { #[command(verbatim_doc_comment)] /// 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. /// /// [commandbody]() /// # ``` /// # yaml content here /// # ``` // Allowed because the doc comment gets parsed by our code later #[allow(clippy::doc_markdown)] RegisterAppservice, /// Unregister an appservice using its ID /// /// You can find the ID using the `list-appservices` command. UnregisterAppservice { /// The appservice to unregister appservice_identifier: String, }, /// List all the currently registered appservices ListAppservices, /// List all rooms the server knows about ListRooms, /// List users in the database ListLocalUsers, /// List all rooms we are currently handling an incoming pdu from IncomingFederation, /// Deactivate a user /// /// User will not be removed from all rooms by default. /// Use --leave-rooms to force the user to leave all rooms DeactivateUser { #[arg(short, long)] leave_rooms: bool, user_id: Box, }, #[command(verbatim_doc_comment)] /// 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. /// /// [commandbody]() /// # ``` /// # User list here /// # ``` // Allowed because the doc comment gets parsed by our code later #[allow(clippy::doc_markdown)] DeactivateAll { #[arg(short, long)] /// Remove users from their joined rooms leave_rooms: bool, #[arg(short, long)] /// Also deactivate admin accounts force: bool, }, /// Get the `auth_chain` of a PDU GetAuthChain { /// An event ID (the $ character followed by the base64 reference hash) event_id: Box, }, #[command(verbatim_doc_comment)] /// Parse and print a PDU from a JSON /// /// The PDU event is only checked for validity and is not added to the /// database. /// /// [commandbody]() /// # ``` /// # PDU json content here /// # ``` // Allowed because the doc comment gets parsed by our code later #[allow(clippy::doc_markdown)] ParsePdu, /// Retrieve and print a PDU by ID from the Grapevine database GetPdu { /// An event ID (a $ followed by the base64 reference hash) event_id: Box, }, /// Print database memory usage statistics MemoryUsage, /// Clears all of Grapevine's database caches with index smaller than the /// amount ClearDatabaseCaches { amount: u32, }, /// Clears all of Grapevine's service caches with index smaller than the /// amount ClearServiceCaches { amount: u32, }, /// Show configuration values ShowConfig, /// Reset user password ResetPassword { /// Username of the user for whom the password should be reset username: String, }, /// Create a new user CreateUser { /// Username of the new user username: String, /// Password of the new user, if unspecified one is generated password: Option, }, /// 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 /// [commandbody]() /// # ``` /// # json here /// # ``` // Allowed because the doc comment gets parsed by our code later #[allow(clippy::doc_markdown)] SignJson, /// Verify json signatures /// [commandbody]() /// # ``` /// # json here /// # ``` // Allowed because the doc comment gets parsed by our code later #[allow(clippy::doc_markdown)] VerifyJson, } #[derive(Debug)] pub(crate) enum AdminRoomEvent { ProcessMessage(String), SendMessage(RoomMessageEventContent), } pub(crate) struct Service { pub(crate) sender: mpsc::UnboundedSender, receiver: Mutex>, } impl Service { pub(crate) fn build() -> Arc { let (sender, receiver) = mpsc::unbounded_channel(); Arc::new(Self { sender, receiver: Mutex::new(receiver), }) } pub(crate) fn start_handler(self: &Arc) { let self2 = Arc::clone(self); tokio::spawn(async move { let mut receiver = self2.receiver.lock().await; let Ok(Some(grapevine_room)) = services().admin.get_admin_room() else { return; }; loop { let event = receiver .recv() .await .expect("admin command channel has been closed"); Self::handle_event(&self2, event, &grapevine_room).await; } }); } #[tracing::instrument(skip(self, grapevine_room))] async fn handle_event( &self, event: AdminRoomEvent, grapevine_room: &OwnedRoomId, ) { let message_content = match event { AdminRoomEvent::SendMessage(content) => content, AdminRoomEvent::ProcessMessage(room_message) => { self.process_admin_message(room_message).await } }; let mutex_state = Arc::clone( services() .globals .roomid_mutex_state .write() .await .entry(grapevine_room.clone()) .or_default(), ); let state_lock = mutex_state.lock().await; services() .rooms .timeline .build_and_append_pdu( PduBuilder { event_type: TimelineEventType::RoomMessage, content: to_raw_value(&message_content) .expect("event is valid, we just created it"), unsigned: None, state_key: None, redacts: None, }, &services().globals.admin_bot_user_id, grapevine_room, &state_lock, ) .await .unwrap(); } #[tracing::instrument( skip(self, room_message), fields( room_message = truncate_str_for_debug(&room_message, 50).as_ref(), ), )] pub(crate) fn process_message(&self, room_message: String) { self.sender.send(AdminRoomEvent::ProcessMessage(room_message)).unwrap(); } #[tracing::instrument(skip(self, message_content))] pub(crate) fn send_message( &self, message_content: RoomMessageEventContent, ) { self.sender.send(AdminRoomEvent::SendMessage(message_content)).unwrap(); } // Parse and process a message from the admin room #[tracing::instrument( skip(self, room_message), fields( room_message = truncate_str_for_debug(&room_message, 50).as_ref(), ), )] async fn process_admin_message( &self, room_message: String, ) -> RoomMessageEventContent { let mut lines = room_message.lines().filter(|l| !l.trim().is_empty()); let command_line = lines.next().expect("each string has at least one line"); let body: Vec<_> = lines.collect(); let admin_command = match Self::parse_admin_command(command_line) { Ok(command) => command, Err(error) => { let server_name = services().globals.server_name(); let message = error.replace("server.name", server_name.as_str()); let html_message = Self::usage_to_html(&message, server_name); return RoomMessageEventContent::text_html( message, html_message, ); } }; match self.process_admin_command(admin_command, body).await { Ok(reply_message) => reply_message, Err(error) => { let markdown_message = format!( "Encountered an error while handling the \ command:\n```\n{error}\n```", ); let html_message = format!( "Encountered an error while handling the \ command:\n
\n{error}\n
", ); RoomMessageEventContent::text_html( markdown_message, html_message, ) } } } // Parse chat messages from the admin room into an AdminCommand object #[tracing::instrument( skip(command_line), fields( command_line = truncate_str_for_debug(command_line, 50).as_ref(), ), )] fn parse_admin_command( command_line: &str, ) -> std::result::Result { // Note: argv[0] is `@grapevine:servername:`, which is treated as the // main command let mut argv: Vec<_> = command_line.split_whitespace().collect(); // Replace `help command` with `command --help` // Clap has a help subcommand, but it omits the long help description. if argv.len() > 1 && argv[1] == "help" { argv.remove(1); argv.push("--help"); } // Backwards compatibility with `register_appservice`-style commands let command_with_dashes; if argv.len() > 1 && argv[1].contains('_') { command_with_dashes = argv[1].replace('_', "-"); argv[1] = &command_with_dashes; } AdminCommand::try_parse_from(argv).map_err(|error| error.to_string()) } #[allow(clippy::too_many_lines)] #[tracing::instrument(skip(self, body))] async fn process_admin_command( &self, command: AdminCommand, body: Vec<&str>, ) -> Result { let reply_message_content = match command { AdminCommand::RegisterAppservice => { if body.len() > 2 && body[0].trim() == "```" && 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.", ) } } AdminCommand::UnregisterAppservice { 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}" )), }, AdminCommand::ListAppservices => { let appservices = services().appservice.iter_ids().await; let output = format!( "Appservices ({}): {}", appservices.len(), appservices.join(", ") ); RoomMessageEventContent::text_plain(output) } AdminCommand::ListRooms => { let room_ids = services().rooms.metadata.iter_ids(); let output = format!( "Rooms:\n{}", room_ids .filter_map(std::result::Result::ok) .map(|id| format!( "{id}\tMembers: {}", &services() .rooms .state_cache .room_joined_count(&id) .ok() .flatten() .unwrap_or(0) )) .collect::>() .join("\n") ); RoomMessageEventContent::text_plain(output) } AdminCommand::ListLocalUsers => match services() .users .list_local_users() { Ok(users) => { let mut msg: String = 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()), }, AdminCommand::IncomingFederation => { let map = services().globals.roomid_federationhandletime.read().await; let mut msg: String = format!("Handling {} incoming pdus:\n", map.len()); for (r, (e, i)) in map.iter() { let elapsed = i.elapsed(); writeln!( msg, "{r} {e}: {}m{}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60 ) .expect("write to in-memory buffer should succeed"); } RoomMessageEventContent::text_plain(&msg) } AdminCommand::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.") } } AdminCommand::ParsePdu => { if body.len() > 2 && body[0].trim() == "```" && 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.", ) } } AdminCommand::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"); RoomMessageEventContent::text_html( format!( "{}\n```json\n{}\n```", if outlier { "PDU is outlier" } else { "PDU was accepted" }, json_text ), format!( "

{}

\n
{}\n
\n", if outlier { "PDU is outlier" } else { "PDU was accepted" }, html_escape::encode_safe(&json_text) ), ) } None => { RoomMessageEventContent::text_plain("PDU not found.") } } } AdminCommand::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}" )) } AdminCommand::ClearDatabaseCaches { amount, } => { services().globals.db.clear_caches(amount); RoomMessageEventContent::text_plain("Done.") } AdminCommand::ClearServiceCaches { amount, } => { services().clear_caches(amount).await; RoomMessageEventContent::text_plain("Done.") } AdminCommand::ShowConfig => { // Construct and send the response RoomMessageEventContent::text_plain(format!( "{}", services().globals.config )) } AdminCommand::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}" ), )) } }; // Checks if user is local if user_id.server_name() != services().globals.server_name() { return Ok(RoomMessageEventContent::text_plain( "The specified user is not from this server!", )); }; // Check if the specified user is valid if !services().users.exists(&user_id)? || user_id == services().globals.admin_bot_user_id { 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}" )), } } AdminCommand::CreateUser { 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 displayname = user_id.localpart().to_owned(); services() .users .set_displayname(&user_id, Some(displayname))?; // Initial account data services().account_data.update( None, &user_id, ruma::events::GlobalAccountDataEventType::PushRules .to_string() .into(), &serde_json::to_value(PushRulesEvent { content: PushRulesEventContent { global: ruma::push::Ruleset::server_default( &user_id, ), }, }) .expect("to json value always works"), )?; // 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}" )) } AdminCommand::DisableRoom { room_id, } => { services().rooms.metadata.disable_room(&room_id, true)?; RoomMessageEventContent::text_plain("Room disabled.") } AdminCommand::EnableRoom { room_id, } => { services().rooms.metadata.disable_room(&room_id, false)?; RoomMessageEventContent::text_plain("Room enabled.") } AdminCommand::DeactivateUser { leave_rooms, user_id, } => { let user_id = Arc::::from(user_id); if !services().users.exists(&user_id)? { RoomMessageEventContent::text_plain(format!( "User {user_id} doesn't exist on this server" )) } else if user_id.server_name() != services().globals.server_name() { RoomMessageEventContent::text_plain(format!( "User {user_id} is not from this server" )) } else { 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" )) } } AdminCommand::DeactivateAll { leave_rooms, force, } => { if body.len() > 2 && body[0].trim() == "```" && body.last().unwrap().trim() == "```" { let users = body .clone() .drain(1..body.len() - 1) .collect::>(); let mut user_ids = Vec::new(); let mut remote_ids = Vec::new(); let mut non_existant_ids = Vec::new(); let mut invalid_users = Vec::new(); for &user in &users { match <&UserId>::try_from(user) { Ok(user_id) => { if user_id.server_name() != services().globals.server_name() { remote_ids.push(user_id); } else if !services().users.exists(user_id)? { non_existant_ids.push(user_id); } else { user_ids.push(user_id); } } Err(_) => { invalid_users.push(user); } } } let mut markdown_message = String::new(); let mut html_message = String::new(); if !invalid_users.is_empty() { markdown_message.push_str( "The following user ids are not valid:\n```\n", ); html_message.push_str( "The following user ids are not valid:\n
\n",
                        );
                        for invalid_user in invalid_users {
                            writeln!(markdown_message, "{invalid_user}")
                                .expect(
                                    "write to in-memory buffer should succeed",
                                );
                            writeln!(html_message, "{invalid_user}").expect(
                                "write to in-memory buffer should succeed",
                            );
                        }
                        markdown_message.push_str("```\n\n");
                        html_message.push_str("
\n\n"); } if !remote_ids.is_empty() { markdown_message.push_str( "The following users are not from this \ server:\n```\n", ); html_message.push_str( "The following users are not from this \ server:\n
\n",
                        );
                        for remote_id in remote_ids {
                            writeln!(markdown_message, "{remote_id}").expect(
                                "write to in-memory buffer should succeed",
                            );
                            writeln!(html_message, "{remote_id}").expect(
                                "write to in-memory buffer should succeed",
                            );
                        }
                        markdown_message.push_str("```\n\n");
                        html_message.push_str("
\n\n"); } if !non_existant_ids.is_empty() { markdown_message.push_str( "The following users do not exist:\n```\n", ); html_message.push_str( "The following users do not exist:\n
\n",
                        );
                        for non_existant_id in non_existant_ids {
                            writeln!(markdown_message, "{non_existant_id}")
                                .expect(
                                    "write to in-memory buffer should succeed",
                                );
                            writeln!(html_message, "{non_existant_id}").expect(
                                "write to in-memory buffer should succeed",
                            );
                        }
                        markdown_message.push_str("```\n\n");
                        html_message.push_str("
\n\n"); } if !markdown_message.is_empty() { return Ok(RoomMessageEventContent::text_html( markdown_message, html_message, )); } 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 { if services().users.deactivate_account(user_id).is_ok() { deactivation_count += 1; } } if leave_rooms { for &user_id in &user_ids { if let Err(error) = leave_all_rooms(user_id).await { warn!(%user_id, %error, "failed to leave one or more rooms"); } } } 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.", ) } } AdminCommand::SignJson => { if body.len() > 2 && body[0].trim() == "```" && 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.", ) } } AdminCommand::VerifyJson => { if body.len() > 2 && body[0].trim() == "```" && 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 // Generally we shouldn't be checking against // expired keys unless required, so in the admin // room it might be best to not allow expired // keys .fetch_required_signing_keys( &value, &pub_key_map ) .await?; let mut expired_key_map = BTreeMap::new(); let mut valid_key_map = BTreeMap::new(); for (server, keys) in pub_key_map.into_inner() { if keys.valid_until_ts > MilliSecondsSinceUnixEpoch::now() { valid_key_map.insert( server, keys.verify_keys .into_iter() .map(|(id, key)| (id, key.key)) .collect(), ); } else { expired_key_map.insert( server, keys.verify_keys .into_iter() .map(|(id, key)| (id, key.key)) .collect(), ); } } if verify_json(&valid_key_map, &value).is_ok() { RoomMessageEventContent::text_plain( "Signature correct", ) } else if let Err(e) = verify_json(&expired_key_map, &value) { RoomMessageEventContent::text_plain(format!( "Signature verification failed: {e}" )) } else { RoomMessageEventContent::text_plain( "Signature correct (with expired keys)", ) } } Err(e) => RoomMessageEventContent::text_plain(format!( "Invalid json: {e}" )), } } else { RoomMessageEventContent::text_plain( "Expected code block in command body. Add --help for \ details.", ) } } }; Ok(reply_message_content) } // Utility to turn clap's `--help` text to HTML. #[tracing::instrument(skip_all)] fn usage_to_html(text: &str, server_name: &ServerName) -> String { // Replace `@grapevine:servername:-subcmdname` with // `@grapevine:servername: subcmdname` let localpart = services().globals.admin_bot_user_id.localpart(); let text = text.replace( &format!("@{localpart}:{server_name}:-"), &format!("@{localpart}:{server_name}: "), ); // For the grapevine admin room, subcommands become main commands let text = text.replace("SUBCOMMAND", "COMMAND"); let text = text.replace("subcommand", "command"); // Escape option names (e.g. ``) since they look like HTML // tags let text = text.replace('<', "<").replace('>', ">"); // Italicize the first line (command name and version text) let re = Regex::new("^(.*?)\n").expect("Regex compilation should not fail"); let text = re.replace_all(&text, "$1\n"); // Unmerge wrapped lines let text = text.replace("\n ", " "); // Wrap option names in backticks. The lines look like: // -V, --version Prints version information // And are converted to: // -V, --version: Prints version information // (?m) enables multi-line mode for ^ and $ let re = Regex::new("(?m)^ (([a-zA-Z_&;-]+(, )?)+) +(.*)$") .expect("Regex compilation should not fail"); let text = re.replace_all(&text, "$1: $4"); // Look for a `[commandbody]()` tag. If it exists, use all lines below // it that start with a `#` in the USAGE section. let mut text_lines: Vec<&str> = text.lines().collect(); let command_body = text_lines .iter() .skip_while(|x| x != &&"[commandbody]()") .skip(1) .map_while(|&x| x.strip_prefix('#')) .map(|x| x.strip_prefix(' ').unwrap_or(x)) .collect::(); text_lines.retain(|x| x != &"[commandbody]()"); let text = text_lines.join("\n"); // Improve the usage section let text = if command_body.is_empty() { // Wrap the usage line in code tags let re = Regex::new("(?m)^USAGE:\n (@grapevine:.*)$") .expect("Regex compilation should not fail"); re.replace_all(&text, "USAGE:\n$1").to_string() } else { // Wrap the usage line in a code block, and add a yaml block example // This makes the usage of e.g. `register-appservice` more accurate let re = Regex::new("(?m)^USAGE:\n (.*?)\n\n") .expect("Regex compilation should not fail"); re.replace_all( &text, "USAGE:\n
$1[nobr]\n[commandbodyblock]
", ) .replace("[commandbodyblock]", &command_body) }; // Add HTML line-breaks text.replace("\n\n\n", "\n\n") .replace('\n', "
\n") .replace("[nobr]
", "") } /// Create the admin room. /// /// Users in this room are considered admins by grapevine, and the room can /// be used to issue admin commands by talking to the server user inside /// it. #[allow(clippy::too_many_lines)] #[tracing::instrument(skip(self))] pub(crate) async fn create_admin_room(&self) -> Result<()> { let room_id = RoomId::new(services().globals.server_name()); services().rooms.short.get_or_create_shortroomid(&room_id)?; let mutex_state = Arc::clone( services() .globals .roomid_mutex_state .write() .await .entry(room_id.clone()) .or_default(), ); let state_lock = mutex_state.lock().await; services().users.create(&services().globals.admin_bot_user_id, None)?; let room_version = services().globals.default_room_version(); let mut content = match room_version { RoomVersionId::V1 | RoomVersionId::V2 | RoomVersionId::V3 | RoomVersionId::V4 | RoomVersionId::V5 | RoomVersionId::V6 | RoomVersionId::V7 | RoomVersionId::V8 | RoomVersionId::V9 | RoomVersionId::V10 => RoomCreateEventContent::new_v1( services().globals.admin_bot_user_id.clone(), ), RoomVersionId::V11 => RoomCreateEventContent::new_v11(), _ => unreachable!("Validity of room version already checked"), }; content.federate = true; content.predecessor = None; content.room_version = room_version; // 1. The room create event services() .rooms .timeline .build_and_append_pdu( PduBuilder { event_type: TimelineEventType::RoomCreate, content: to_raw_value(&content) .expect("event is valid, we just created it"), unsigned: None, state_key: Some(String::new()), redacts: None, }, &services().globals.admin_bot_user_id, &room_id, &state_lock, ) .await?; // 2. Make grapevine bot join services() .rooms .timeline .build_and_append_pdu( PduBuilder { event_type: TimelineEventType::RoomMember, content: to_raw_value(&RoomMemberEventContent { membership: MembershipState::Join, displayname: None, avatar_url: None, is_direct: None, third_party_invite: None, blurhash: None, reason: None, join_authorized_via_users_server: None, }) .expect("event is valid, we just created it"), unsigned: None, state_key: Some( services().globals.admin_bot_user_id.to_string(), ), redacts: None, }, &services().globals.admin_bot_user_id, &room_id, &state_lock, ) .await?; // 3. Power levels let mut users = BTreeMap::new(); users.insert(services().globals.admin_bot_user_id.clone(), 100.into()); services() .rooms .timeline .build_and_append_pdu( PduBuilder { event_type: TimelineEventType::RoomPowerLevels, content: to_raw_value(&RoomPowerLevelsEventContent { users, ..Default::default() }) .expect("event is valid, we just created it"), unsigned: None, state_key: Some(String::new()), redacts: None, }, &services().globals.admin_bot_user_id, &room_id, &state_lock, ) .await?; // 4.1 Join Rules services() .rooms .timeline .build_and_append_pdu( PduBuilder { event_type: TimelineEventType::RoomJoinRules, content: to_raw_value(&RoomJoinRulesEventContent::new( JoinRule::Invite, )) .expect("event is valid, we just created it"), unsigned: None, state_key: Some(String::new()), redacts: None, }, &services().globals.admin_bot_user_id, &room_id, &state_lock, ) .await?; // 4.2 History Visibility services() .rooms .timeline .build_and_append_pdu( PduBuilder { event_type: TimelineEventType::RoomHistoryVisibility, content: to_raw_value( &RoomHistoryVisibilityEventContent::new( HistoryVisibility::Shared, ), ) .expect("event is valid, we just created it"), unsigned: None, state_key: Some(String::new()), redacts: None, }, &services().globals.admin_bot_user_id, &room_id, &state_lock, ) .await?; // 4.3 Guest Access services() .rooms .timeline .build_and_append_pdu( PduBuilder { event_type: TimelineEventType::RoomGuestAccess, content: to_raw_value(&RoomGuestAccessEventContent::new( GuestAccess::Forbidden, )) .expect("event is valid, we just created it"), unsigned: None, state_key: Some(String::new()), redacts: None, }, &services().globals.admin_bot_user_id, &room_id, &state_lock, ) .await?; // 5. Events implied by name and topic let room_name = format!("{} Admin Room", services().globals.server_name()); services() .rooms .timeline .build_and_append_pdu( PduBuilder { event_type: TimelineEventType::RoomName, content: to_raw_value(&RoomNameEventContent::new( room_name, )) .expect("event is valid, we just created it"), unsigned: None, state_key: Some(String::new()), redacts: None, }, &services().globals.admin_bot_user_id, &room_id, &state_lock, ) .await?; services() .rooms .timeline .build_and_append_pdu( PduBuilder { event_type: TimelineEventType::RoomTopic, content: to_raw_value(&RoomTopicEventContent { topic: format!( "Manage {}", services().globals.server_name() ), }) .expect("event is valid, we just created it"), unsigned: None, state_key: Some(String::new()), redacts: None, }, &services().globals.admin_bot_user_id, &room_id, &state_lock, ) .await?; // 6. Room alias let alias = &services().globals.admin_bot_room_alias_id; services() .rooms .timeline .build_and_append_pdu( PduBuilder { event_type: TimelineEventType::RoomCanonicalAlias, content: to_raw_value(&RoomCanonicalAliasEventContent { alias: Some(alias.clone()), alt_aliases: Vec::new(), }) .expect("event is valid, we just created it"), unsigned: None, state_key: Some(String::new()), redacts: None, }, &services().globals.admin_bot_user_id, &room_id, &state_lock, ) .await?; services().rooms.alias.set_alias(alias, &room_id)?; Ok(()) } /// Gets the room ID of the admin room /// /// Errors are propagated from the database, and will have None if there is /// no admin room // Allowed because this function uses `services()` #[allow(clippy::unused_self)] pub(crate) fn get_admin_room(&self) -> Result> { services() .rooms .alias .resolve_local_alias(&services().globals.admin_bot_room_alias_id) } /// Invite the user to the grapevine admin room. /// /// In grapevine, this is equivalent to granting admin privileges. #[tracing::instrument(skip(self))] pub(crate) async fn make_user_admin( &self, user_id: &UserId, displayname: String, ) -> Result<()> { if let Some(room_id) = services().admin.get_admin_room()? { let mutex_state = Arc::clone( services() .globals .roomid_mutex_state .write() .await .entry(room_id.clone()) .or_default(), ); let state_lock = mutex_state.lock().await; // Use the server user to grant the new admin's power level // Invite and join the real user services() .rooms .timeline .build_and_append_pdu( PduBuilder { event_type: TimelineEventType::RoomMember, content: to_raw_value(&RoomMemberEventContent { membership: MembershipState::Invite, displayname: None, avatar_url: None, is_direct: None, third_party_invite: None, blurhash: None, reason: None, join_authorized_via_users_server: None, }) .expect("event is valid, we just created it"), unsigned: None, state_key: Some(user_id.to_string()), redacts: None, }, &services().globals.admin_bot_user_id, &room_id, &state_lock, ) .await?; services() .rooms .timeline .build_and_append_pdu( PduBuilder { event_type: TimelineEventType::RoomMember, content: to_raw_value(&RoomMemberEventContent { membership: MembershipState::Join, displayname: Some(displayname), avatar_url: None, is_direct: None, third_party_invite: None, blurhash: None, reason: None, join_authorized_via_users_server: None, }) .expect("event is valid, we just created it"), unsigned: None, state_key: Some(user_id.to_string()), redacts: None, }, user_id, &room_id, &state_lock, ) .await?; // Set power level let mut users = BTreeMap::new(); users.insert( services().globals.admin_bot_user_id.clone(), 100.into(), ); users.insert(user_id.to_owned(), 100.into()); services() .rooms .timeline .build_and_append_pdu( PduBuilder { event_type: TimelineEventType::RoomPowerLevels, content: to_raw_value(&RoomPowerLevelsEventContent { users, ..Default::default() }) .expect("event is valid, we just created it"), unsigned: None, state_key: Some(String::new()), redacts: None, }, &services().globals.admin_bot_user_id, &room_id, &state_lock, ) .await?; } Ok(()) } } #[cfg(test)] mod test { use super::*; #[test] fn get_help_short() { get_help_inner("-h"); } #[test] fn get_help_long() { get_help_inner("--help"); } #[test] fn get_help_subcommand() { get_help_inner("help"); } fn get_help_inner(input: &str) { let error = AdminCommand::try_parse_from(["argv[0] doesn't matter", input]) .unwrap_err() .to_string(); // Search for a handful of keywords that suggest the help printed // properly assert!(error.contains("Usage:")); assert!(error.contains("Commands:")); assert!(error.contains("Options:")); } }