mirror of
https://gitlab.computer.surgery/matrix/grapevine.git
synced 2025-12-17 07:41:23 +01:00
1346 lines
52 KiB
Rust
1346 lines
52 KiB
Rust
use std::{
|
|
collections::BTreeMap,
|
|
convert::{TryFrom, TryInto},
|
|
fmt::Write,
|
|
sync::Arc,
|
|
time::Instant,
|
|
};
|
|
|
|
use clap::Parser;
|
|
use regex::Regex;
|
|
use ruma::{
|
|
api::appservice::Registration,
|
|
events::{
|
|
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,
|
|
},
|
|
EventId, OwnedRoomAliasId, OwnedRoomId, RoomAliasId, RoomId, RoomVersionId, ServerName, UserId,
|
|
};
|
|
use serde_json::value::to_raw_value;
|
|
use tokio::sync::{mpsc, Mutex, RwLock};
|
|
|
|
use crate::{
|
|
api::client_server::{leave_all_rooms, AUTO_GEN_PASSWORD_LENGTH},
|
|
services,
|
|
utils::{self, HtmlEscape},
|
|
Error, PduEvent, Result,
|
|
};
|
|
|
|
use super::pdu::PduBuilder;
|
|
|
|
#[cfg_attr(test, derive(Debug))]
|
|
#[derive(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
|
|
/// # ```
|
|
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
|
|
/// # ```
|
|
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
|
|
/// # ```
|
|
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>,
|
|
},
|
|
|
|
/// 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<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> },
|
|
|
|
/// Verify json signatures
|
|
/// [commandbody]()
|
|
/// # ```
|
|
/// # json here
|
|
/// # ```
|
|
SignJson,
|
|
|
|
/// Verify json signatures
|
|
/// [commandbody]()
|
|
/// # ```
|
|
/// # json here
|
|
/// # ```
|
|
VerifyJson,
|
|
}
|
|
|
|
#[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>>,
|
|
}
|
|
|
|
impl Service {
|
|
pub(crate) fn build() -> 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 {
|
|
self2.handler().await;
|
|
});
|
|
}
|
|
|
|
async fn handler(&self) {
|
|
let mut receiver = self.receiver.lock().await;
|
|
// TODO: Use futures when we have long admin commands
|
|
//let mut futures = FuturesUnordered::new();
|
|
|
|
let grapevine_user =
|
|
UserId::parse(format!("@grapevine:{}", services().globals.server_name()))
|
|
.expect("@grapevine:server_name is valid");
|
|
|
|
if let Ok(Some(grapevine_room)) = services().admin.get_admin_room() {
|
|
loop {
|
|
tokio::select! {
|
|
Some(event) = receiver.recv() => {
|
|
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.to_owned())
|
|
.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,
|
|
},
|
|
&grapevine_user,
|
|
&grapevine_room,
|
|
&state_lock,
|
|
)
|
|
.await.unwrap();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn process_message(&self, room_message: String) {
|
|
self.sender
|
|
.send(AdminRoomEvent::ProcessMessage(room_message))
|
|
.unwrap();
|
|
}
|
|
|
|
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
|
|
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
|
|
fn parse_admin_command(&self, 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())
|
|
}
|
|
|
|
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(|r| r.ok())
|
|
.map(|id| id.to_string()
|
|
+ "\tMembers: "
|
|
+ &services()
|
|
.rooms
|
|
.state_cache
|
|
.room_joined_count(&id)
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or(0)
|
|
.to_string())
|
|
.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"
|
|
},
|
|
HtmlEscape(&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
|
|
== UserId::parse_with_server_name(
|
|
"grapevine",
|
|
services().globals.server_name(),
|
|
)
|
|
.expect("grapevine 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}"
|
|
)),
|
|
}
|
|
}
|
|
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(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"),
|
|
)?;
|
|
|
|
// 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::<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) => match is_admin {
|
|
true => {
|
|
admins.push(user_id.localpart());
|
|
false
|
|
}
|
|
false => 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 {
|
|
let _ = 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.",
|
|
)
|
|
}
|
|
}
|
|
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
|
|
.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.",
|
|
)
|
|
}
|
|
}
|
|
};
|
|
|
|
Ok(reply_message_content)
|
|
}
|
|
|
|
// Utility to turn clap's `--help` text to HTML.
|
|
fn usage_to_html(&self, text: &str, server_name: &ServerName) -> String {
|
|
// Replace `@grapevine:servername:-subcmdname` with `@grapevine:servername: subcmdname`
|
|
let text = text.replace(
|
|
&format!("@grapevine:{server_name}:-"),
|
|
&format!("@grapevine:{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('<', "<").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, "<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 mut command_body = String::new();
|
|
|
|
if let Some(line_index) = text_lines
|
|
.iter()
|
|
.position(|line| *line == "[commandbody]()")
|
|
{
|
|
text_lines.remove(line_index);
|
|
|
|
while text_lines
|
|
.get(line_index)
|
|
.map(|line| line.starts_with('#'))
|
|
.unwrap_or(false)
|
|
{
|
|
command_body += if text_lines[line_index].starts_with("# ") {
|
|
&text_lines[line_index][2..]
|
|
} else {
|
|
&text_lines[line_index][1..]
|
|
};
|
|
command_body += "[nobr]\n";
|
|
text_lines.remove(line_index);
|
|
}
|
|
}
|
|
|
|
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.
|
|
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;
|
|
|
|
// Create a user for the server
|
|
let grapevine_user =
|
|
UserId::parse_with_server_name("grapevine", services().globals.server_name())
|
|
.expect("@grapevine:server_name is valid");
|
|
|
|
services().users.create(&grapevine_user, 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(grapevine_user.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("".to_owned()),
|
|
redacts: None,
|
|
},
|
|
&grapevine_user,
|
|
&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(grapevine_user.to_string()),
|
|
redacts: None,
|
|
},
|
|
&grapevine_user,
|
|
&room_id,
|
|
&state_lock,
|
|
)
|
|
.await?;
|
|
|
|
// 3. Power levels
|
|
let mut users = BTreeMap::new();
|
|
users.insert(grapevine_user.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("".to_owned()),
|
|
redacts: None,
|
|
},
|
|
&grapevine_user,
|
|
&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("".to_owned()),
|
|
redacts: None,
|
|
},
|
|
&grapevine_user,
|
|
&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("".to_owned()),
|
|
redacts: None,
|
|
},
|
|
&grapevine_user,
|
|
&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("".to_owned()),
|
|
redacts: None,
|
|
},
|
|
&grapevine_user,
|
|
&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("".to_owned()),
|
|
redacts: None,
|
|
},
|
|
&grapevine_user,
|
|
&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("".to_owned()),
|
|
redacts: None,
|
|
},
|
|
&grapevine_user,
|
|
&room_id,
|
|
&state_lock,
|
|
)
|
|
.await?;
|
|
|
|
// 6. Room alias
|
|
let alias: OwnedRoomAliasId = format!("#admins:{}", services().globals.server_name())
|
|
.try_into()
|
|
.expect("#admins:server_name is a valid alias name");
|
|
|
|
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("".to_owned()),
|
|
redacts: None,
|
|
},
|
|
&grapevine_user,
|
|
&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
|
|
pub(crate) fn get_admin_room(&self) -> Result<Option<OwnedRoomId>> {
|
|
let admin_room_alias: Box<RoomAliasId> =
|
|
format!("#admins:{}", services().globals.server_name())
|
|
.try_into()
|
|
.expect("#admins:server_name is a valid alias name");
|
|
|
|
services()
|
|
.rooms
|
|
.alias
|
|
.resolve_local_alias(&admin_room_alias)
|
|
}
|
|
|
|
/// Invite the user to the grapevine admin room.
|
|
///
|
|
/// In grapevine, this is equivalent to granting admin privileges.
|
|
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
|
|
let grapevine_user =
|
|
UserId::parse_with_server_name("grapevine", services().globals.server_name())
|
|
.expect("@grapevine:server_name is valid");
|
|
|
|
// 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,
|
|
},
|
|
&grapevine_user,
|
|
&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(grapevine_user.to_owned(), 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("".to_owned()),
|
|
redacts: None,
|
|
},
|
|
&grapevine_user,
|
|
&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:"));
|
|
}
|
|
}
|