mirror of
https://gitlab.computer.surgery/matrix/grapevine.git
synced 2025-12-17 15:51:23 +01:00
To clear caches, restart the server. We may want to consider adding the cache sizes and database memory usage as metrics in the future.
1634 lines
61 KiB
Rust
1634 lines
61 KiB
Rust
use std::{collections::BTreeMap, fmt::Write, sync::Arc, time::Instant};
|
|
|
|
use clap::{Parser, ValueEnum};
|
|
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, 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
|
|
SetTracingFilter {
|
|
backend: TracingBackend,
|
|
filter: String,
|
|
},
|
|
}
|
|
|
|
#[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 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 {
|
|
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(
|
|
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::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;
|
|
}
|
|
}
|
|
});
|
|
|
|
while let Some(mxc) = rx.recv().await {
|
|
let Ok(mxc) = mxc else {
|
|
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;
|
|
}
|
|
}
|
|
|
|
count += 1;
|
|
if !dry_run {
|
|
services().media.delete(mxc).await?;
|
|
}
|
|
}
|
|
|
|
let message = if dry_run {
|
|
format!("{count} media objects would be deleted.")
|
|
} else {
|
|
format!("{count} media objects deleted.")
|
|
};
|
|
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::SetTracingFilter {
|
|
backend,
|
|
filter,
|
|
} => {
|
|
let handles = &services().globals.reload_handles;
|
|
let handle = match backend {
|
|
TracingBackend::Log => &handles.log,
|
|
TracingBackend::Flame => &handles.flame,
|
|
TracingBackend::Traces => &handles.traces,
|
|
};
|
|
let Some(handle) = handle else {
|
|
return Ok(RoomMessageEventContent::text_plain(
|
|
"Backend is disabled",
|
|
));
|
|
};
|
|
let filter = match filter.parse() {
|
|
Ok(filter) => filter,
|
|
Err(e) => {
|
|
return Ok(RoomMessageEventContent::text_plain(
|
|
format!("Invalid filter string: {e}"),
|
|
));
|
|
}
|
|
};
|
|
if let Err(e) = handle.reload(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('<', "<").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 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:"));
|
|
}
|
|
}
|