grapevine/src/service/admin.rs
Olivia Lee b82458a460
use event content in account_data service api instead of full events
This eliminates the possibility of passing an event that has a
mismatching type, reducing the space of possible invalid states.
2025-06-02 11:07:21 -07:00

1717 lines
64 KiB
Rust

use std::{collections::BTreeMap, fmt::Write, sync::Arc, time::Instant};
use clap::{Parser, Subcommand, ValueEnum};
use regex::Regex;
use ruma::{
api::appservice::Registration,
events::{
push_rules::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,
},
serde::Raw,
signatures::verify_json,
EventId, MilliSecondsSinceUnixEpoch, OwnedMxcUri, OwnedRoomId,
OwnedServerName, 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, dbg_truncate_str, room_version::RoomVersion},
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<UserId>,
},
#[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<EventId>,
},
#[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<EventId>,
},
/// 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<String>,
},
/// Disables incoming federation handling for a room.
DisableRoom {
room_id: Box<RoomId>,
},
/// Enables incoming federation handling for a room again.
EnableRoom {
room_id: Box<RoomId>,
},
/// Delete media and all associated thumbnails.
DeleteMedia {
/// mxc:// URI of the media to delete
mxc: OwnedMxcUri,
},
/// Delete cached remote media from the database.
///
/// This media may still be fetched and cached again in the future.
DeleteRemoteMedia {
/// Output the number of media objects that would be deleted, but do
/// not actually delete anything.
#[clap(short, long)]
dry_run: bool,
/// If specified, only delete remote media from this origin.
///
/// If not specified, all remote media will be deleted.
#[clap(long)]
origin: Option<OwnedServerName>,
},
/// 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,
/// Dynamically change a tracing backend's filter string
TracingFilter {
#[command(subcommand)]
cmd: TracingFilterCommand,
},
}
#[derive(Debug, Subcommand)]
enum TracingFilterCommand {
Get {
backend: TracingBackend,
},
Set {
backend: TracingBackend,
filter: String,
},
Reset {
backend: TracingBackend,
},
}
impl TracingFilterCommand {
fn backend(&self) -> &TracingBackend {
match self {
TracingFilterCommand::Get {
backend,
}
| TracingFilterCommand::Set {
backend,
..
}
| TracingFilterCommand::Reset {
backend,
} => backend,
}
}
}
#[derive(Debug)]
pub(crate) enum AdminRoomEvent {
ProcessMessage(String),
SendMessage(RoomMessageEventContent),
}
pub(crate) struct Service {
pub(crate) sender: mpsc::UnboundedSender<AdminRoomEvent>,
receiver: Mutex<mpsc::UnboundedReceiver<AdminRoomEvent>>,
}
#[derive(Debug, Clone, ValueEnum)]
enum TracingBackend {
Log,
Flame,
Traces,
}
impl Service {
pub(crate) fn new() -> Arc<Self> {
let (sender, receiver) = mpsc::unbounded_channel();
Arc::new(Self {
sender,
receiver: Mutex::new(receiver),
})
}
pub(crate) fn start_handler(self: &Arc<Self>) {
let self2 = Arc::clone(self);
tokio::spawn(async move {
let mut receiver = self2.receiver.lock().await;
let Ok(Some(grapevine_room)) = self2.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 room_token = services()
.globals
.roomid_mutex_state
.lock_key(grapevine_room.clone())
.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,
&room_token,
)
.await
.unwrap();
}
#[tracing::instrument(
skip(self, room_message),
fields(
room_message = dbg_truncate_str(&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 = dbg_truncate_str(&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<pre>\n{error}\n</pre>",
);
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 = dbg_truncate_str(command_line, 50).as_ref(),
),
)]
fn parse_admin_command(
command_line: &str,
) -> std::result::Result<AdminCommand, String> {
// 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<RoomMessageEventContent> {
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::<Registration>(
&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::<Vec<_>>()
.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::<EventId>::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::<PduEvent>(
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!(
"<p>{}</p>\n<pre><code \
class=\"language-json\">{}\n</code></pre>\n",
if outlier {
"PDU is outlier"
} else {
"PDU was accepted"
},
html_escape::encode_safe(&json_text)
),
)
}
None => {
RoomMessageEventContent::text_plain("PDU not found.")
}
}
}
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_global(
&user_id,
&ruma::events::GlobalAccountDataEventType::PushRules,
&Raw::new(
&PushRulesEventContent {
global: ruma::push::Ruleset::server_default(
&user_id,
),
}
.into(),
)
.expect("json serialization should always succeed"),
)?;
// 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::DeleteMedia {
mxc,
} => {
services().media.delete(mxc).await?;
RoomMessageEventContent::text_plain("Media deleted.")
}
AdminCommand::DeleteRemoteMedia {
dry_run,
origin,
} => {
if origin.as_deref() == Some(services().globals.server_name()) {
return Ok(RoomMessageEventContent::text_plain(
"Specified origin is this server. Will not delete \
anything.",
));
}
let mut count = 0;
// The `media.iter_all()` iterator is not `Send`, so spawn it in
// a separate thread and send the results over a channel.
let (tx, mut rx) = mpsc::channel(1);
tokio::task::spawn_blocking(move || {
for mxc in services().media.iter_all() {
if tx.blocking_send(mxc).is_err() {
break;
}
}
});
let mut failed_keys = 0;
let mut failed_deletes = 0;
while let Some(mxc) = rx.recv().await {
let Ok(mxc) = mxc else {
// Error details are logged by media::iter_all
failed_keys += 1;
continue;
};
let server_name = mxc.server_name();
if server_name == Ok(services().globals.server_name()) {
continue;
}
if let Some(origin) = &origin {
if server_name != Ok(origin) {
continue;
}
}
// Technically this can be collapsed, but relying on &&
// short-circuiting to avoid the delete side-effect is
// confusing.
#[allow(clippy::collapsible_if)]
if !dry_run {
if services().media.delete(mxc).await.is_err() {
// Error details are logged by media::delete
failed_deletes += 1;
continue;
}
}
count += 1;
}
let mut message = if dry_run {
format!("{count} media objects would be deleted.")
} else {
format!("{count} media objects deleted.")
};
if failed_keys != 0 {
write!(
message,
"\n{failed_keys} corrupted media keys found in the \
database."
)
.unwrap();
}
if failed_deletes != 0 {
write!(
message,
"\n{failed_deletes} media objects failed to delete."
)
.unwrap();
}
if failed_keys != 0 || failed_deletes != 0 {
write!(
message,
"\nCheck the server logs for more details."
)
.unwrap();
}
RoomMessageEventContent::text_plain(message)
}
AdminCommand::DeactivateUser {
leave_rooms,
user_id,
} => {
let user_id = Arc::<UserId>::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::<Vec<_>>();
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<pre>\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("</pre>\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<pre>\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("</pre>\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<pre>\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("</pre>\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.",
)
}
}
AdminCommand::TracingFilter {
cmd,
} => {
let Some(handles) = &services().globals.reload_handles else {
return Ok(RoomMessageEventContent::text_plain(
"Reloading filters is disabled",
));
};
let mut handles = handles.write().await;
let handle = match cmd.backend() {
TracingBackend::Log => &mut handles.log,
TracingBackend::Flame => &mut handles.flame,
TracingBackend::Traces => &mut handles.traces,
};
let Some(handle) = handle.as_mut() else {
return Ok(RoomMessageEventContent::text_plain(
"Backend is disabled",
));
};
let filter = match cmd {
TracingFilterCommand::Set {
filter,
..
} => filter,
TracingFilterCommand::Reset {
..
} => handle.get_initial_filter().to_owned(),
TracingFilterCommand::Get {
..
} => {
return Ok(RoomMessageEventContent::text_plain(
format!(
"Current filter string: {}",
handle.get_filter()
),
));
}
};
if let Err(e) = handle.set_filter(filter) {
return Ok(RoomMessageEventContent::text_plain(format!(
"Failed to reload filter: {e}"
)));
};
return Ok(RoomMessageEventContent::text_plain(
"Filter reloaded",
));
}
};
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. `<element-id>`) since they look like HTML
// tags
let text = text.replace('<', "&lt;").replace('>', "&gt;");
// 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, "<em>$1</em>\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:
// <code>-V, --version</code>: 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, "<code>$1</code>: $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::<String>();
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<code>$1</code>").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<pre>$1[nobr]\n[commandbodyblock]</pre>",
)
.replace("[commandbodyblock]", &command_body)
};
// Add HTML line-breaks
text.replace("\n\n\n", "\n\n")
.replace('\n', "<br>\n")
.replace("[nobr]<br>", "")
}
/// 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 room_token = services()
.globals
.roomid_mutex_state
.lock_key(room_id.clone())
.await;
services().users.create(&services().globals.admin_bot_user_id, None)?;
let room_version_id = services().globals.default_room_version();
let room_version = RoomVersion::try_from(&room_version_id)?;
let mut content = if room_version.create_event_creator_prop {
RoomCreateEventContent::new_v1(
services().globals.admin_bot_user_id.clone(),
)
} else {
RoomCreateEventContent::new_v11()
};
content.federate = true;
content.predecessor = None;
content.room_version = room_version_id;
// 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_token,
)
.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_token,
)
.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_token,
)
.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_token,
)
.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_token,
)
.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_token,
)
.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_token,
)
.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_token,
)
.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_token,
)
.await?;
services().rooms.alias.set_alias(
alias,
&room_id,
&services().globals.admin_bot_user_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<Option<OwnedRoomId>> {
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) = self.get_admin_room()? {
let room_token = services()
.globals
.roomid_mutex_state
.lock_key(room_id.clone())
.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_token,
)
.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_token,
)
.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_token,
)
.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:"));
}
}