From 893707d501049a5894d6e017b54e49f9a1f7293b Mon Sep 17 00:00:00 2001
From: strawberry <strawberry@puppygock.gay>
Date: Sat, 20 Apr 2024 19:55:14 -0400
Subject: [PATCH] finish general admin room cleanup

Signed-off-by: strawberry <strawberry@puppygock.gay>
---
 src/service/admin/federation.rs               | 172 --------------
 .../admin/federation/federation_commands.rs   | 126 ++++++++++
 src/service/admin/federation/mod.rs           |  68 ++++++
 src/service/admin/media.rs                    | 216 ------------------
 src/service/admin/media/media_commands.rs     | 171 ++++++++++++++
 src/service/admin/media/mod.rs                |  49 ++++
 src/service/admin/mod.rs                      |   3 -
 src/service/admin/query/account_data.rs       |  32 +--
 src/service/admin/query/appservice.rs         |  13 +-
 src/service/admin/query/globals.rs            |  23 +-
 src/service/admin/query/mod.rs                | 114 ++++++++-
 src/service/admin/query/presence.rs           |  22 +-
 src/service/admin/query/room_alias.rs         |  23 +-
 src/service/admin/room.rs                     |  96 --------
 src/service/admin/room/mod.rs                 | 160 +++++++++++++
 .../room_alias_commands.rs}                   |  40 +---
 src/service/admin/room/room_commands.rs       |  59 +++++
 .../room_directory_commands.rs}               |  29 +--
 .../room_moderation_commands.rs}              |  60 +----
 19 files changed, 751 insertions(+), 725 deletions(-)
 delete mode 100644 src/service/admin/federation.rs
 create mode 100644 src/service/admin/federation/federation_commands.rs
 create mode 100644 src/service/admin/federation/mod.rs
 delete mode 100644 src/service/admin/media.rs
 create mode 100644 src/service/admin/media/media_commands.rs
 create mode 100644 src/service/admin/media/mod.rs
 delete mode 100644 src/service/admin/room.rs
 create mode 100644 src/service/admin/room/mod.rs
 rename src/service/admin/{room_alias.rs => room/room_alias_commands.rs} (84%)
 create mode 100644 src/service/admin/room/room_commands.rs
 rename src/service/admin/{room_directory.rs => room/room_directory_commands.rs} (80%)
 rename src/service/admin/{room_moderation.rs => room/room_moderation_commands.rs} (90%)

diff --git a/src/service/admin/federation.rs b/src/service/admin/federation.rs
deleted file mode 100644
index c7a611034..000000000
--- a/src/service/admin/federation.rs
+++ /dev/null
@@ -1,172 +0,0 @@
-use std::{collections::BTreeMap, fmt::Write as _};
-
-use clap::Subcommand;
-use ruma::{events::room::message::RoomMessageEventContent, RoomId, ServerName};
-use tokio::sync::RwLock;
-
-use crate::{services, utils::HtmlEscape, Result};
-
-#[cfg_attr(test, derive(Debug))]
-#[derive(Subcommand)]
-pub(crate) enum FederationCommand {
-	/// - List all rooms we are currently handling an incoming pdu from
-	IncomingFederation,
-
-	/// - Disables incoming federation handling for a room.
-	DisableRoom {
-		room_id: Box<RoomId>,
-	},
-
-	/// - Enables incoming federation handling for a room again.
-	EnableRoom {
-		room_id: Box<RoomId>,
-	},
-
-	/// - Verify json signatures
-	///
-	/// This command needs a JSON blob provided in a Markdown code block below
-	/// the command.
-	SignJson,
-
-	/// - Verify json signatures
-	///
-	/// This command needs a JSON blob provided in a Markdown code block below
-	/// the command.
-	VerifyJson,
-
-	/// - Fetch `/.well-known/matrix/support` from the specified server
-	///
-	/// Despite the name, this is not a federation endpoint and does not go
-	/// through the federation / server resolution process as per-spec this is
-	/// supposed to be served at the server_name.
-	///
-	/// Respecting homeservers put this file here for listing administration,
-	/// moderation, and security inquiries. This command provides a way to
-	/// easily fetch that information.
-	FetchSupportWellKnown {
-		server_name: Box<ServerName>,
-	},
-}
-
-pub(crate) async fn process(command: FederationCommand, body: Vec<&str>) -> Result<RoomMessageEventContent> {
-	match command {
-		FederationCommand::DisableRoom {
-			room_id,
-		} => {
-			services().rooms.metadata.disable_room(&room_id, true)?;
-			Ok(RoomMessageEventContent::text_plain("Room disabled."))
-		},
-		FederationCommand::EnableRoom {
-			room_id,
-		} => {
-			services().rooms.metadata.disable_room(&room_id, false)?;
-			Ok(RoomMessageEventContent::text_plain("Room enabled."))
-		},
-		FederationCommand::IncomingFederation => {
-			let map = services().globals.roomid_federationhandletime.read().await;
-			let mut msg = format!("Handling {} incoming pdus:\n", map.len());
-
-			for (r, (e, i)) in map.iter() {
-				let elapsed = i.elapsed();
-				let _ = writeln!(msg, "{} {}: {}m{}s", r, e, elapsed.as_secs() / 60, elapsed.as_secs() % 60);
-			}
-			Ok(RoomMessageEventContent::text_plain(&msg))
-		},
-		FederationCommand::SignJson => {
-			if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" {
-				let string = body[1..body.len() - 1].join("\n");
-				match serde_json::from_str(&string) {
-					Ok(mut value) => {
-						ruma::signatures::sign_json(
-							services().globals.server_name().as_str(),
-							services().globals.keypair(),
-							&mut value,
-						)
-						.expect("our request json is what ruma expects");
-						let json_text = serde_json::to_string_pretty(&value).expect("canonical json is valid json");
-						Ok(RoomMessageEventContent::text_plain(json_text))
-					},
-					Err(e) => Ok(RoomMessageEventContent::text_plain(format!("Invalid json: {e}"))),
-				}
-			} else {
-				Ok(RoomMessageEventContent::text_plain(
-					"Expected code block in command body. Add --help for details.",
-				))
-			}
-		},
-		FederationCommand::VerifyJson => {
-			if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" {
-				let string = body[1..body.len() - 1].join("\n");
-				match serde_json::from_str(&string) {
-					Ok(value) => {
-						let pub_key_map = RwLock::new(BTreeMap::new());
-
-						services()
-							.rooms
-							.event_handler
-							.fetch_required_signing_keys([&value], &pub_key_map)
-							.await?;
-
-						let pub_key_map = pub_key_map.read().await;
-						match ruma::signatures::verify_json(&pub_key_map, &value) {
-							Ok(()) => Ok(RoomMessageEventContent::text_plain("Signature correct")),
-							Err(e) => Ok(RoomMessageEventContent::text_plain(format!(
-								"Signature verification failed: {e}"
-							))),
-						}
-					},
-					Err(e) => Ok(RoomMessageEventContent::text_plain(format!("Invalid json: {e}"))),
-				}
-			} else {
-				Ok(RoomMessageEventContent::text_plain(
-					"Expected code block in command body. Add --help for details.",
-				))
-			}
-		},
-		FederationCommand::FetchSupportWellKnown {
-			server_name,
-		} => {
-			let response = services()
-				.globals
-				.client
-				.default
-				.get(format!("https://{server_name}/.well-known/matrix/support"))
-				.send()
-				.await?;
-
-			let text = response.text().await?;
-
-			if text.is_empty() {
-				return Ok(RoomMessageEventContent::text_plain("Response text/body is empty."));
-			}
-
-			if text.len() > 1500 {
-				return Ok(RoomMessageEventContent::text_plain(
-					"Response text/body is over 1500 characters, assuming no support well-known.",
-				));
-			}
-
-			let json: serde_json::Value = match serde_json::from_str(&text) {
-				Ok(json) => json,
-				Err(_) => {
-					return Ok(RoomMessageEventContent::text_plain("Response text/body is not valid JSON."));
-				},
-			};
-
-			let pretty_json: String = match serde_json::to_string_pretty(&json) {
-				Ok(json) => json,
-				Err(_) => {
-					return Ok(RoomMessageEventContent::text_plain("Response text/body is not valid JSON."));
-				},
-			};
-
-			Ok(RoomMessageEventContent::text_html(
-				format!("Got JSON response:\n\n```json\n{pretty_json}\n```"),
-				format!(
-					"<p>Got JSON response:</p>\n<pre><code class=\"language-json\">{}\n</code></pre>\n",
-					HtmlEscape(&pretty_json)
-				),
-			))
-		},
-	}
-}
diff --git a/src/service/admin/federation/federation_commands.rs b/src/service/admin/federation/federation_commands.rs
new file mode 100644
index 000000000..845c2f914
--- /dev/null
+++ b/src/service/admin/federation/federation_commands.rs
@@ -0,0 +1,126 @@
+use std::{collections::BTreeMap, fmt::Write as _};
+
+use ruma::{events::room::message::RoomMessageEventContent, RoomId, ServerName};
+use tokio::sync::RwLock;
+
+use crate::{services, utils::HtmlEscape, Result};
+
+pub(super) async fn disable_room(_body: Vec<&str>, room_id: Box<RoomId>) -> Result<RoomMessageEventContent> {
+	services().rooms.metadata.disable_room(&room_id, true)?;
+	Ok(RoomMessageEventContent::text_plain("Room disabled."))
+}
+
+pub(super) async fn enable_room(_body: Vec<&str>, room_id: Box<RoomId>) -> Result<RoomMessageEventContent> {
+	services().rooms.metadata.disable_room(&room_id, false)?;
+	Ok(RoomMessageEventContent::text_plain("Room enabled."))
+}
+
+pub(super) async fn incoming_federeation(_body: Vec<&str>) -> Result<RoomMessageEventContent> {
+	let map = services().globals.roomid_federationhandletime.read().await;
+	let mut msg = format!("Handling {} incoming pdus:\n", map.len());
+
+	for (r, (e, i)) in map.iter() {
+		let elapsed = i.elapsed();
+		let _ = writeln!(msg, "{} {}: {}m{}s", r, e, elapsed.as_secs() / 60, elapsed.as_secs() % 60);
+	}
+	Ok(RoomMessageEventContent::text_plain(&msg))
+}
+
+pub(super) async fn sign_json(body: Vec<&str>) -> Result<RoomMessageEventContent> {
+	if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" {
+		let string = body[1..body.len() - 1].join("\n");
+		match serde_json::from_str(&string) {
+			Ok(mut value) => {
+				ruma::signatures::sign_json(
+					services().globals.server_name().as_str(),
+					services().globals.keypair(),
+					&mut value,
+				)
+				.expect("our request json is what ruma expects");
+				let json_text = serde_json::to_string_pretty(&value).expect("canonical json is valid json");
+				Ok(RoomMessageEventContent::text_plain(json_text))
+			},
+			Err(e) => Ok(RoomMessageEventContent::text_plain(format!("Invalid json: {e}"))),
+		}
+	} else {
+		Ok(RoomMessageEventContent::text_plain(
+			"Expected code block in command body. Add --help for details.",
+		))
+	}
+}
+
+pub(super) async fn verify_json(body: Vec<&str>) -> Result<RoomMessageEventContent> {
+	if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" {
+		let string = body[1..body.len() - 1].join("\n");
+		match serde_json::from_str(&string) {
+			Ok(value) => {
+				let pub_key_map = RwLock::new(BTreeMap::new());
+
+				services()
+					.rooms
+					.event_handler
+					.fetch_required_signing_keys([&value], &pub_key_map)
+					.await?;
+
+				let pub_key_map = pub_key_map.read().await;
+				match ruma::signatures::verify_json(&pub_key_map, &value) {
+					Ok(()) => Ok(RoomMessageEventContent::text_plain("Signature correct")),
+					Err(e) => Ok(RoomMessageEventContent::text_plain(format!(
+						"Signature verification failed: {e}"
+					))),
+				}
+			},
+			Err(e) => Ok(RoomMessageEventContent::text_plain(format!("Invalid json: {e}"))),
+		}
+	} else {
+		Ok(RoomMessageEventContent::text_plain(
+			"Expected code block in command body. Add --help for details.",
+		))
+	}
+}
+
+pub(super) async fn fetch_support_well_known(
+	_body: Vec<&str>, server_name: Box<ServerName>,
+) -> Result<RoomMessageEventContent> {
+	let response = services()
+		.globals
+		.client
+		.default
+		.get(format!("https://{server_name}/.well-known/matrix/support"))
+		.send()
+		.await?;
+
+	let text = response.text().await?;
+
+	if text.is_empty() {
+		return Ok(RoomMessageEventContent::text_plain("Response text/body is empty."));
+	}
+
+	if text.len() > 1500 {
+		return Ok(RoomMessageEventContent::text_plain(
+			"Response text/body is over 1500 characters, assuming no support well-known.",
+		));
+	}
+
+	let json: serde_json::Value = match serde_json::from_str(&text) {
+		Ok(json) => json,
+		Err(_) => {
+			return Ok(RoomMessageEventContent::text_plain("Response text/body is not valid JSON."));
+		},
+	};
+
+	let pretty_json: String = match serde_json::to_string_pretty(&json) {
+		Ok(json) => json,
+		Err(_) => {
+			return Ok(RoomMessageEventContent::text_plain("Response text/body is not valid JSON."));
+		},
+	};
+
+	Ok(RoomMessageEventContent::text_html(
+		format!("Got JSON response:\n\n```json\n{pretty_json}\n```"),
+		format!(
+			"<p>Got JSON response:</p>\n<pre><code class=\"language-json\">{}\n</code></pre>\n",
+			HtmlEscape(&pretty_json)
+		),
+	))
+}
diff --git a/src/service/admin/federation/mod.rs b/src/service/admin/federation/mod.rs
new file mode 100644
index 000000000..74878e36a
--- /dev/null
+++ b/src/service/admin/federation/mod.rs
@@ -0,0 +1,68 @@
+use clap::Subcommand;
+use ruma::{events::room::message::RoomMessageEventContent, RoomId, ServerName};
+
+use self::federation_commands::{
+	disable_room, enable_room, fetch_support_well_known, incoming_federeation, sign_json, verify_json,
+};
+use crate::Result;
+
+pub(crate) mod federation_commands;
+
+#[cfg_attr(test, derive(Debug))]
+#[derive(Subcommand)]
+pub(crate) enum FederationCommand {
+	/// - List all rooms we are currently handling an incoming pdu from
+	IncomingFederation,
+
+	/// - Disables incoming federation handling for a room.
+	DisableRoom {
+		room_id: Box<RoomId>,
+	},
+
+	/// - Enables incoming federation handling for a room again.
+	EnableRoom {
+		room_id: Box<RoomId>,
+	},
+
+	/// - Verify json signatures
+	///
+	/// This command needs a JSON blob provided in a Markdown code block below
+	/// the command.
+	SignJson,
+
+	/// - Verify json signatures
+	///
+	/// This command needs a JSON blob provided in a Markdown code block below
+	/// the command.
+	VerifyJson,
+
+	/// - Fetch `/.well-known/matrix/support` from the specified server
+	///
+	/// Despite the name, this is not a federation endpoint and does not go
+	/// through the federation / server resolution process as per-spec this is
+	/// supposed to be served at the server_name.
+	///
+	/// Respecting homeservers put this file here for listing administration,
+	/// moderation, and security inquiries. This command provides a way to
+	/// easily fetch that information.
+	FetchSupportWellKnown {
+		server_name: Box<ServerName>,
+	},
+}
+
+pub(crate) async fn process(command: FederationCommand, body: Vec<&str>) -> Result<RoomMessageEventContent> {
+	Ok(match command {
+		FederationCommand::DisableRoom {
+			room_id,
+		} => disable_room(body, room_id).await?,
+		FederationCommand::EnableRoom {
+			room_id,
+		} => enable_room(body, room_id).await?,
+		FederationCommand::IncomingFederation => incoming_federeation(body).await?,
+		FederationCommand::SignJson => sign_json(body).await?,
+		FederationCommand::VerifyJson => verify_json(body).await?,
+		FederationCommand::FetchSupportWellKnown {
+			server_name,
+		} => fetch_support_well_known(body, server_name).await?,
+	})
+}
diff --git a/src/service/admin/media.rs b/src/service/admin/media.rs
deleted file mode 100644
index ee86401e5..000000000
--- a/src/service/admin/media.rs
+++ /dev/null
@@ -1,216 +0,0 @@
-use clap::Subcommand;
-use ruma::{events::room::message::RoomMessageEventContent, EventId};
-use tracing::{debug, info};
-
-use crate::{service::admin::MxcUri, services, Result};
-
-#[cfg_attr(test, derive(Debug))]
-#[derive(Subcommand)]
-pub(crate) enum MediaCommand {
-	/// - Deletes a single media file from our database and on the filesystem
-	///   via a single MXC URL
-	Delete {
-		/// The MXC URL to delete
-		#[arg(long)]
-		mxc: Option<Box<MxcUri>>,
-
-		/// - The message event ID which contains the media and thumbnail MXC
-		///   URLs
-		#[arg(long)]
-		event_id: Option<Box<EventId>>,
-	},
-
-	/// - Deletes a codeblock list of MXC URLs from our database and on the
-	///   filesystem
-	DeleteList,
-
-	/// - Deletes all remote media in the last X amount of time using filesystem
-	///   metadata first created at date.
-	DeletePastRemoteMedia {
-		/// - The duration (at or after), e.g. "5m" to delete all media in the
-		///   past 5 minutes
-		duration: String,
-	},
-}
-
-pub(crate) async fn process(command: MediaCommand, body: Vec<&str>) -> Result<RoomMessageEventContent> {
-	match command {
-		MediaCommand::Delete {
-			mxc,
-			event_id,
-		} => {
-			if event_id.is_some() && mxc.is_some() {
-				return Ok(RoomMessageEventContent::text_plain(
-					"Please specify either an MXC or an event ID, not both.",
-				));
-			}
-
-			if let Some(mxc) = mxc {
-				if !mxc.to_string().starts_with("mxc://") {
-					return Ok(RoomMessageEventContent::text_plain("MXC provided is not valid."));
-				}
-
-				debug!("Got MXC URL: {}", mxc);
-				services().media.delete(mxc.to_string()).await?;
-
-				return Ok(RoomMessageEventContent::text_plain(
-					"Deleted the MXC from our database and on our filesystem.",
-				));
-			} else if let Some(event_id) = event_id {
-				debug!("Got event ID to delete media from: {}", event_id);
-
-				let mut mxc_urls = vec![];
-				let mut mxc_deletion_count = 0;
-
-				// parsing the PDU for any MXC URLs begins here
-				if let Some(event_json) = services().rooms.timeline.get_pdu_json(&event_id)? {
-					if let Some(content_key) = event_json.get("content") {
-						debug!("Event ID has \"content\".");
-						let content_obj = content_key.as_object();
-
-						if let Some(content) = content_obj {
-							// 1. attempts to parse the "url" key
-							debug!("Attempting to go into \"url\" key for main media file");
-							if let Some(url) = content.get("url") {
-								debug!("Got a URL in the event ID {event_id}: {url}");
-
-								if url.to_string().starts_with("\"mxc://") {
-									debug!("Pushing URL {} to list of MXCs to delete", url);
-									let final_url = url.to_string().replace('"', "");
-									mxc_urls.push(final_url);
-								} else {
-									info!(
-										"Found a URL in the event ID {event_id} but did not start with mxc://, \
-										 ignoring"
-									);
-								}
-							}
-
-							// 2. attempts to parse the "info" key
-							debug!("Attempting to go into \"info\" key for thumbnails");
-							if let Some(info_key) = content.get("info") {
-								debug!("Event ID has \"info\".");
-								let info_obj = info_key.as_object();
-
-								if let Some(info) = info_obj {
-									if let Some(thumbnail_url) = info.get("thumbnail_url") {
-										debug!("Found a thumbnail_url in info key: {thumbnail_url}");
-
-										if thumbnail_url.to_string().starts_with("\"mxc://") {
-											debug!("Pushing thumbnail URL {} to list of MXCs to delete", thumbnail_url);
-											let final_thumbnail_url = thumbnail_url.to_string().replace('"', "");
-											mxc_urls.push(final_thumbnail_url);
-										} else {
-											info!(
-												"Found a thumbnail URL in the event ID {event_id} but did not start \
-												 with mxc://, ignoring"
-											);
-										}
-									} else {
-										info!("No \"thumbnail_url\" key in \"info\" key, assuming no thumbnails.");
-									}
-								}
-							}
-
-							// 3. attempts to parse the "file" key
-							debug!("Attempting to go into \"file\" key");
-							if let Some(file_key) = content.get("file") {
-								debug!("Event ID has \"file\".");
-								let file_obj = file_key.as_object();
-
-								if let Some(file) = file_obj {
-									if let Some(url) = file.get("url") {
-										debug!("Found url in file key: {url}");
-
-										if url.to_string().starts_with("\"mxc://") {
-											debug!("Pushing URL {} to list of MXCs to delete", url);
-											let final_url = url.to_string().replace('"', "");
-											mxc_urls.push(final_url);
-										} else {
-											info!(
-												"Found a URL in the event ID {event_id} but did not start with \
-												 mxc://, ignoring"
-											);
-										}
-									} else {
-										info!("No \"url\" key in \"file\" key.");
-									}
-								}
-							}
-						} else {
-							return Ok(RoomMessageEventContent::text_plain(
-								"Event ID does not have a \"content\" key or failed parsing the event ID JSON.",
-							));
-						}
-					} else {
-						return Ok(RoomMessageEventContent::text_plain(
-							"Event ID does not have a \"content\" key, this is not a message or an event type that \
-							 contains media.",
-						));
-					}
-				} else {
-					return Ok(RoomMessageEventContent::text_plain(
-						"Event ID does not exist or is not known to us.",
-					));
-				}
-
-				if mxc_urls.is_empty() {
-					// we shouldn't get here (should have errored earlier) but just in case for
-					// whatever reason we do...
-					info!("Parsed event ID {event_id} but did not contain any MXC URLs.");
-					return Ok(RoomMessageEventContent::text_plain("Parsed event ID but found no MXC URLs."));
-				}
-
-				for mxc_url in mxc_urls {
-					services().media.delete(mxc_url).await?;
-					mxc_deletion_count += 1;
-				}
-
-				return Ok(RoomMessageEventContent::text_plain(format!(
-					"Deleted {mxc_deletion_count} total MXCs from our database and the filesystem from event ID \
-					 {event_id}."
-				)));
-			}
-
-			Ok(RoomMessageEventContent::text_plain(
-				"Please specify either an MXC using --mxc or an event ID using --event-id of the message containing \
-				 an image. See --help for details.",
-			))
-		},
-		MediaCommand::DeleteList => {
-			if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" {
-				let mxc_list = body.clone().drain(1..body.len() - 1).collect::<Vec<_>>();
-
-				let mut mxc_deletion_count = 0;
-
-				for mxc in mxc_list {
-					debug!("Deleting MXC {} in bulk", mxc);
-					services().media.delete(mxc.to_owned()).await?;
-					mxc_deletion_count += 1;
-				}
-
-				return Ok(RoomMessageEventContent::text_plain(format!(
-					"Finished bulk MXC deletion, deleted {} total MXCs from our database and the filesystem.",
-					mxc_deletion_count
-				)));
-			}
-
-			Ok(RoomMessageEventContent::text_plain(
-				"Expected code block in command body. Add --help for details.",
-			))
-		},
-		MediaCommand::DeletePastRemoteMedia {
-			duration,
-		} => {
-			let deleted_count = services()
-				.media
-				.delete_all_remote_media_at_after_time(duration)
-				.await?;
-
-			Ok(RoomMessageEventContent::text_plain(format!(
-				"Deleted {} total files.",
-				deleted_count
-			)))
-		},
-	}
-}
diff --git a/src/service/admin/media/media_commands.rs b/src/service/admin/media/media_commands.rs
new file mode 100644
index 000000000..d77450887
--- /dev/null
+++ b/src/service/admin/media/media_commands.rs
@@ -0,0 +1,171 @@
+use ruma::{events::room::message::RoomMessageEventContent, EventId};
+use tracing::{debug, info};
+
+use crate::{service::admin::MxcUri, services, Result};
+
+pub(super) async fn delete(
+	_body: Vec<&str>, mxc: Option<Box<MxcUri>>, event_id: Option<Box<EventId>>,
+) -> Result<RoomMessageEventContent> {
+	if event_id.is_some() && mxc.is_some() {
+		return Ok(RoomMessageEventContent::text_plain(
+			"Please specify either an MXC or an event ID, not both.",
+		));
+	}
+
+	if let Some(mxc) = mxc {
+		debug!("Got MXC URL: {mxc}");
+		services().media.delete(mxc.to_string()).await?;
+
+		return Ok(RoomMessageEventContent::text_plain(
+			"Deleted the MXC from our database and on our filesystem.",
+		));
+	} else if let Some(event_id) = event_id {
+		debug!("Got event ID to delete media from: {event_id}");
+
+		let mut mxc_urls = vec![];
+		let mut mxc_deletion_count = 0;
+
+		// parsing the PDU for any MXC URLs begins here
+		if let Some(event_json) = services().rooms.timeline.get_pdu_json(&event_id)? {
+			if let Some(content_key) = event_json.get("content") {
+				debug!("Event ID has \"content\".");
+				let content_obj = content_key.as_object();
+
+				if let Some(content) = content_obj {
+					// 1. attempts to parse the "url" key
+					debug!("Attempting to go into \"url\" key for main media file");
+					if let Some(url) = content.get("url") {
+						debug!("Got a URL in the event ID {event_id}: {url}");
+
+						if url.to_string().starts_with("\"mxc://") {
+							debug!("Pushing URL {url} to list of MXCs to delete");
+							let final_url = url.to_string().replace('"', "");
+							mxc_urls.push(final_url);
+						} else {
+							info!("Found a URL in the event ID {event_id} but did not start with mxc://, ignoring");
+						}
+					}
+
+					// 2. attempts to parse the "info" key
+					debug!("Attempting to go into \"info\" key for thumbnails");
+					if let Some(info_key) = content.get("info") {
+						debug!("Event ID has \"info\".");
+						let info_obj = info_key.as_object();
+
+						if let Some(info) = info_obj {
+							if let Some(thumbnail_url) = info.get("thumbnail_url") {
+								debug!("Found a thumbnail_url in info key: {thumbnail_url}");
+
+								if thumbnail_url.to_string().starts_with("\"mxc://") {
+									debug!("Pushing thumbnail URL {thumbnail_url} to list of MXCs to delete");
+									let final_thumbnail_url = thumbnail_url.to_string().replace('"', "");
+									mxc_urls.push(final_thumbnail_url);
+								} else {
+									info!(
+										"Found a thumbnail URL in the event ID {event_id} but did not start with \
+										 mxc://, ignoring"
+									);
+								}
+							} else {
+								info!("No \"thumbnail_url\" key in \"info\" key, assuming no thumbnails.");
+							}
+						}
+					}
+
+					// 3. attempts to parse the "file" key
+					debug!("Attempting to go into \"file\" key");
+					if let Some(file_key) = content.get("file") {
+						debug!("Event ID has \"file\".");
+						let file_obj = file_key.as_object();
+
+						if let Some(file) = file_obj {
+							if let Some(url) = file.get("url") {
+								debug!("Found url in file key: {url}");
+
+								if url.to_string().starts_with("\"mxc://") {
+									debug!("Pushing URL {url} to list of MXCs to delete");
+									let final_url = url.to_string().replace('"', "");
+									mxc_urls.push(final_url);
+								} else {
+									info!(
+										"Found a URL in the event ID {event_id} but did not start with mxc://, \
+										 ignoring"
+									);
+								}
+							} else {
+								info!("No \"url\" key in \"file\" key.");
+							}
+						}
+					}
+				} else {
+					return Ok(RoomMessageEventContent::text_plain(
+						"Event ID does not have a \"content\" key or failed parsing the event ID JSON.",
+					));
+				}
+			} else {
+				return Ok(RoomMessageEventContent::text_plain(
+					"Event ID does not have a \"content\" key, this is not a message or an event type that contains \
+					 media.",
+				));
+			}
+		} else {
+			return Ok(RoomMessageEventContent::text_plain(
+				"Event ID does not exist or is not known to us.",
+			));
+		}
+
+		if mxc_urls.is_empty() {
+			// we shouldn't get here (should have errored earlier) but just in case for
+			// whatever reason we do...
+			info!("Parsed event ID {event_id} but did not contain any MXC URLs.");
+			return Ok(RoomMessageEventContent::text_plain("Parsed event ID but found no MXC URLs."));
+		}
+
+		for mxc_url in mxc_urls {
+			services().media.delete(mxc_url).await?;
+			mxc_deletion_count += 1;
+		}
+
+		return Ok(RoomMessageEventContent::text_plain(format!(
+			"Deleted {mxc_deletion_count} total MXCs from our database and the filesystem from event ID {event_id}."
+		)));
+	}
+
+	Ok(RoomMessageEventContent::text_plain(
+		"Please specify either an MXC using --mxc or an event ID using --event-id of the message containing an image. \
+		 See --help for details.",
+	))
+}
+
+pub(super) async fn delete_list(body: Vec<&str>) -> Result<RoomMessageEventContent> {
+	if body.len() > 2 && body[0].trim().starts_with("```") && body.last().unwrap().trim() == "```" {
+		let mxc_list = body.clone().drain(1..body.len() - 1).collect::<Vec<_>>();
+
+		let mut mxc_deletion_count = 0;
+
+		for mxc in mxc_list {
+			debug!("Deleting MXC {mxc} in bulk");
+			services().media.delete(mxc.to_owned()).await?;
+			mxc_deletion_count += 1;
+		}
+
+		return Ok(RoomMessageEventContent::text_plain(format!(
+			"Finished bulk MXC deletion, deleted {mxc_deletion_count} total MXCs from our database and the filesystem.",
+		)));
+	}
+
+	Ok(RoomMessageEventContent::text_plain(
+		"Expected code block in command body. Add --help for details.",
+	))
+}
+
+pub(super) async fn delete_past_remote_media(_body: Vec<&str>, duration: String) -> Result<RoomMessageEventContent> {
+	let deleted_count = services()
+		.media
+		.delete_all_remote_media_at_after_time(duration)
+		.await?;
+
+	Ok(RoomMessageEventContent::text_plain(format!(
+		"Deleted {deleted_count} total files.",
+	)))
+}
diff --git a/src/service/admin/media/mod.rs b/src/service/admin/media/mod.rs
new file mode 100644
index 000000000..d091f94a7
--- /dev/null
+++ b/src/service/admin/media/mod.rs
@@ -0,0 +1,49 @@
+use clap::Subcommand;
+use ruma::{events::room::message::RoomMessageEventContent, EventId};
+
+use self::media_commands::{delete, delete_list, delete_past_remote_media};
+use crate::{service::admin::MxcUri, Result};
+
+pub(crate) mod media_commands;
+
+#[cfg_attr(test, derive(Debug))]
+#[derive(Subcommand)]
+pub(crate) enum MediaCommand {
+	/// - Deletes a single media file from our database and on the filesystem
+	///   via a single MXC URL
+	Delete {
+		/// The MXC URL to delete
+		#[arg(long)]
+		mxc: Option<Box<MxcUri>>,
+
+		/// - The message event ID which contains the media and thumbnail MXC
+		///   URLs
+		#[arg(long)]
+		event_id: Option<Box<EventId>>,
+	},
+
+	/// - Deletes a codeblock list of MXC URLs from our database and on the
+	///   filesystem
+	DeleteList,
+
+	/// - Deletes all remote media in the last X amount of time using filesystem
+	///   metadata first created at date.
+	DeletePastRemoteMedia {
+		/// - The duration (at or after), e.g. "5m" to delete all media in the
+		///   past 5 minutes
+		duration: String,
+	},
+}
+
+pub(crate) async fn process(command: MediaCommand, body: Vec<&str>) -> Result<RoomMessageEventContent> {
+	Ok(match command {
+		MediaCommand::Delete {
+			mxc,
+			event_id,
+		} => delete(body, mxc, event_id).await?,
+		MediaCommand::DeleteList => delete_list(body).await?,
+		MediaCommand::DeletePastRemoteMedia {
+			duration,
+		} => delete_past_remote_media(body, duration).await?,
+	})
+}
diff --git a/src/service/admin/mod.rs b/src/service/admin/mod.rs
index 1971461dc..0f361a4ce 100644
--- a/src/service/admin/mod.rs
+++ b/src/service/admin/mod.rs
@@ -42,9 +42,6 @@
 pub(crate) mod media;
 pub(crate) mod query;
 pub(crate) mod room;
-pub(crate) mod room_alias;
-pub(crate) mod room_directory;
-pub(crate) mod room_moderation;
 pub(crate) mod server;
 pub(crate) mod user;
 
diff --git a/src/service/admin/query/account_data.rs b/src/service/admin/query/account_data.rs
index f335489e3..15d456339 100644
--- a/src/service/admin/query/account_data.rs
+++ b/src/service/admin/query/account_data.rs
@@ -1,36 +1,8 @@
-use clap::Subcommand;
-use ruma::{
-	events::{room::message::RoomMessageEventContent, RoomAccountDataEventType},
-	RoomId, UserId,
-};
+use ruma::events::room::message::RoomMessageEventContent;
 
+use super::AccountData;
 use crate::{services, Result};
 
-#[cfg_attr(test, derive(Debug))]
-#[derive(Subcommand)]
-/// All the getters and iterators from src/database/key_value/account_data.rs
-pub(crate) enum AccountData {
-	/// - Returns all changes to the account data that happened after `since`.
-	ChangesSince {
-		/// Full user ID
-		user_id: Box<UserId>,
-		/// UNIX timestamp since (u64)
-		since: u64,
-		/// Optional room ID of the account data
-		room_id: Option<Box<RoomId>>,
-	},
-
-	/// - Searches the account data for a specific kind.
-	Get {
-		/// Full user ID
-		user_id: Box<UserId>,
-		/// Account data event type
-		kind: RoomAccountDataEventType,
-		/// Optional room ID of the account data
-		room_id: Option<Box<RoomId>>,
-	},
-}
-
 /// All the getters and iterators from src/database/key_value/account_data.rs
 pub(super) async fn account_data(subcommand: AccountData) -> Result<RoomMessageEventContent> {
 	match subcommand {
diff --git a/src/service/admin/query/appservice.rs b/src/service/admin/query/appservice.rs
index c576f7db3..bfb63c955 100644
--- a/src/service/admin/query/appservice.rs
+++ b/src/service/admin/query/appservice.rs
@@ -1,19 +1,8 @@
-use clap::Subcommand;
 use ruma::events::room::message::RoomMessageEventContent;
 
+use super::Appservice;
 use crate::{services, Result};
 
-#[cfg_attr(test, derive(Debug))]
-#[derive(Subcommand)]
-/// All the getters and iterators from src/database/key_value/appservice.rs
-pub(crate) enum Appservice {
-	/// - Gets the appservice registration info/details from the ID as a string
-	GetRegistration {
-		/// Appservice registration ID
-		appservice_id: Box<str>,
-	},
-}
-
 /// All the getters and iterators from src/database/key_value/appservice.rs
 pub(super) async fn appservice(subcommand: Appservice) -> Result<RoomMessageEventContent> {
 	match subcommand {
diff --git a/src/service/admin/query/globals.rs b/src/service/admin/query/globals.rs
index 25c3e3377..ff962cb55 100644
--- a/src/service/admin/query/globals.rs
+++ b/src/service/admin/query/globals.rs
@@ -1,27 +1,8 @@
-use clap::Subcommand;
-use ruma::{events::room::message::RoomMessageEventContent, ServerName};
+use ruma::events::room::message::RoomMessageEventContent;
 
+use super::Globals;
 use crate::{services, Result};
 
-#[cfg_attr(test, derive(Debug))]
-#[derive(Subcommand)]
-/// All the getters and iterators from src/database/key_value/globals.rs
-pub(crate) enum Globals {
-	DatabaseVersion,
-
-	CurrentCount,
-
-	LastCheckForUpdatesId,
-
-	LoadKeypair,
-
-	/// - This returns an empty `Ok(BTreeMap<..>)` when there are no keys found
-	///   for the server.
-	SigningKeysFor {
-		origin: Box<ServerName>,
-	},
-}
-
 /// All the getters and iterators from src/database/key_value/globals.rs
 pub(super) async fn globals(subcommand: Globals) -> Result<RoomMessageEventContent> {
 	match subcommand {
diff --git a/src/service/admin/query/mod.rs b/src/service/admin/query/mod.rs
index 8033e731d..60867fb2f 100644
--- a/src/service/admin/query/mod.rs
+++ b/src/service/admin/query/mod.rs
@@ -5,14 +5,13 @@
 pub(crate) mod room_alias;
 
 use clap::Subcommand;
-use ruma::events::room::message::RoomMessageEventContent;
+use ruma::{
+	events::{room::message::RoomMessageEventContent, RoomAccountDataEventType},
+	RoomAliasId, RoomId, ServerName, UserId,
+};
 
 use self::{
-	account_data::{account_data, AccountData},
-	appservice::{appservice, Appservice},
-	globals::{globals, Globals},
-	presence::{presence, Presence},
-	room_alias::{room_alias, RoomAlias},
+	account_data::account_data, appservice::appservice, globals::globals, presence::presence, room_alias::room_alias,
 };
 use crate::Result;
 
@@ -41,14 +40,105 @@ pub(crate) enum QueryCommand {
 	Globals(Globals),
 }
 
+#[cfg_attr(test, derive(Debug))]
+#[derive(Subcommand)]
+/// All the getters and iterators from src/database/key_value/account_data.rs
+pub(crate) enum AccountData {
+	/// - Returns all changes to the account data that happened after `since`.
+	ChangesSince {
+		/// Full user ID
+		user_id: Box<UserId>,
+		/// UNIX timestamp since (u64)
+		since: u64,
+		/// Optional room ID of the account data
+		room_id: Option<Box<RoomId>>,
+	},
+
+	/// - Searches the account data for a specific kind.
+	Get {
+		/// Full user ID
+		user_id: Box<UserId>,
+		/// Account data event type
+		kind: RoomAccountDataEventType,
+		/// Optional room ID of the account data
+		room_id: Option<Box<RoomId>>,
+	},
+}
+
+#[cfg_attr(test, derive(Debug))]
+#[derive(Subcommand)]
+/// All the getters and iterators from src/database/key_value/appservice.rs
+pub(crate) enum Appservice {
+	/// - Gets the appservice registration info/details from the ID as a string
+	GetRegistration {
+		/// Appservice registration ID
+		appservice_id: Box<str>,
+	},
+}
+
+#[cfg_attr(test, derive(Debug))]
+#[derive(Subcommand)]
+/// All the getters and iterators from src/database/key_value/presence.rs
+pub(crate) enum Presence {
+	/// - Returns the latest presence event for the given user.
+	GetPresence {
+		/// Full user ID
+		user_id: Box<UserId>,
+	},
+
+	/// - Iterator of the most recent presence updates that happened after the
+	///   event with id `since`.
+	PresenceSince {
+		/// UNIX timestamp since (u64)
+		since: u64,
+	},
+}
+
+#[cfg_attr(test, derive(Debug))]
+#[derive(Subcommand)]
+/// All the getters and iterators from src/database/key_value/rooms/alias.rs
+pub(crate) enum RoomAlias {
+	ResolveLocalAlias {
+		/// Full room alias
+		alias: Box<RoomAliasId>,
+	},
+
+	/// - Iterator of all our local room aliases for the room ID
+	LocalAliasesForRoom {
+		/// Full room ID
+		room_id: Box<RoomId>,
+	},
+
+	/// - Iterator of all our local aliases in our database with their room IDs
+	AllLocalAliases,
+}
+
+#[cfg_attr(test, derive(Debug))]
+#[derive(Subcommand)]
+/// All the getters and iterators from src/database/key_value/globals.rs
+pub(crate) enum Globals {
+	DatabaseVersion,
+
+	CurrentCount,
+
+	LastCheckForUpdatesId,
+
+	LoadKeypair,
+
+	/// - This returns an empty `Ok(BTreeMap<..>)` when there are no keys found
+	///   for the server.
+	SigningKeysFor {
+		origin: Box<ServerName>,
+	},
+}
+
 /// Processes admin query commands
-#[allow(non_snake_case)]
 pub(crate) async fn process(command: QueryCommand, _body: Vec<&str>) -> Result<RoomMessageEventContent> {
 	Ok(match command {
-		QueryCommand::AccountData(AccountData) => account_data(AccountData).await?,
-		QueryCommand::Appservice(Appservice) => appservice(Appservice).await?,
-		QueryCommand::Presence(Presence) => presence(Presence).await?,
-		QueryCommand::RoomAlias(RoomAlias) => room_alias(RoomAlias).await?,
-		QueryCommand::Globals(Globals) => globals(Globals).await?,
+		QueryCommand::AccountData(command) => account_data(command).await?,
+		QueryCommand::Appservice(command) => appservice(command).await?,
+		QueryCommand::Presence(command) => presence(command).await?,
+		QueryCommand::RoomAlias(command) => room_alias(command).await?,
+		QueryCommand::Globals(command) => globals(command).await?,
 	})
 }
diff --git a/src/service/admin/query/presence.rs b/src/service/admin/query/presence.rs
index bb55b88fb..0e32bbd7f 100644
--- a/src/service/admin/query/presence.rs
+++ b/src/service/admin/query/presence.rs
@@ -1,26 +1,8 @@
-use clap::Subcommand;
-use ruma::{events::room::message::RoomMessageEventContent, UserId};
+use ruma::events::room::message::RoomMessageEventContent;
 
+use super::Presence;
 use crate::{services, Result};
 
-#[cfg_attr(test, derive(Debug))]
-#[derive(Subcommand)]
-/// All the getters and iterators from src/database/key_value/presence.rs
-pub(crate) enum Presence {
-	/// - Returns the latest presence event for the given user.
-	GetPresence {
-		/// Full user ID
-		user_id: Box<UserId>,
-	},
-
-	/// - Iterator of the most recent presence updates that happened after the
-	///   event with id `since`.
-	PresenceSince {
-		/// UNIX timestamp since (u64)
-		since: u64,
-	},
-}
-
 /// All the getters and iterators in key_value/presence.rs
 pub(super) async fn presence(subcommand: Presence) -> Result<RoomMessageEventContent> {
 	match subcommand {
diff --git a/src/service/admin/query/room_alias.rs b/src/service/admin/query/room_alias.rs
index e854f6434..e5238f381 100644
--- a/src/service/admin/query/room_alias.rs
+++ b/src/service/admin/query/room_alias.rs
@@ -1,27 +1,8 @@
-use clap::Subcommand;
-use ruma::{events::room::message::RoomMessageEventContent, RoomAliasId, RoomId};
+use ruma::events::room::message::RoomMessageEventContent;
 
+use super::RoomAlias;
 use crate::{services, Result};
 
-#[cfg_attr(test, derive(Debug))]
-#[derive(Subcommand)]
-/// All the getters and iterators from src/database/key_value/rooms/alias.rs
-pub(crate) enum RoomAlias {
-	ResolveLocalAlias {
-		/// Full room alias
-		alias: Box<RoomAliasId>,
-	},
-
-	/// - Iterator of all our local room aliases for the room ID
-	LocalAliasesForRoom {
-		/// Full room ID
-		room_id: Box<RoomId>,
-	},
-
-	/// - Iterator of all our local aliases in our database with their room IDs
-	AllLocalAliases,
-}
-
 /// All the getters and iterators in src/database/key_value/rooms/alias.rs
 pub(super) async fn room_alias(subcommand: RoomAlias) -> Result<RoomMessageEventContent> {
 	match subcommand {
diff --git a/src/service/admin/room.rs b/src/service/admin/room.rs
deleted file mode 100644
index 721191b10..000000000
--- a/src/service/admin/room.rs
+++ /dev/null
@@ -1,96 +0,0 @@
-use std::fmt::Write as _;
-
-use clap::Subcommand;
-use ruma::{events::room::message::RoomMessageEventContent, OwnedRoomId};
-
-use crate::{
-	service::admin::{
-		escape_html, get_room_info, room_alias, room_alias::RoomAliasCommand, room_directory,
-		room_directory::RoomDirectoryCommand, room_moderation, room_moderation::RoomModerationCommand, PAGE_SIZE,
-	},
-	services, Result,
-};
-
-#[cfg_attr(test, derive(Debug))]
-#[derive(Subcommand)]
-pub(crate) enum RoomCommand {
-	/// - List all rooms the server knows about
-	List {
-		page: Option<usize>,
-	},
-
-	#[command(subcommand)]
-	/// - Manage moderation of remote or local rooms
-	Moderation(RoomModerationCommand),
-
-	#[command(subcommand)]
-	/// - Manage rooms' aliases
-	Alias(RoomAliasCommand),
-
-	#[command(subcommand)]
-	/// - Manage the room directory
-	Directory(RoomDirectoryCommand),
-}
-
-pub(crate) async fn process(command: RoomCommand, body: Vec<&str>) -> Result<RoomMessageEventContent> {
-	match command {
-		RoomCommand::Alias(command) => room_alias::process(command, body).await,
-
-		RoomCommand::Directory(command) => room_directory::process(command, body).await,
-
-		RoomCommand::Moderation(command) => room_moderation::process(command, body).await,
-
-		RoomCommand::List {
-			page,
-		} => {
-			// TODO: i know there's a way to do this with clap, but i can't seem to find it
-			let page = page.unwrap_or(1);
-			let mut rooms = services()
-				.rooms
-				.metadata
-				.iter_ids()
-				.filter_map(Result::ok)
-				.map(|id: OwnedRoomId| get_room_info(&id))
-				.collect::<Vec<_>>();
-			rooms.sort_by_key(|r| r.1);
-			rooms.reverse();
-
-			let rooms = rooms
-				.into_iter()
-				.skip(page.saturating_sub(1) * PAGE_SIZE)
-				.take(PAGE_SIZE)
-				.collect::<Vec<_>>();
-
-			if rooms.is_empty() {
-				return Ok(RoomMessageEventContent::text_plain("No more rooms."));
-			};
-
-			let output_plain = format!(
-				"Rooms:\n{}",
-				rooms
-					.iter()
-					.map(|(id, members, name)| format!("{id}\tMembers: {members}\tName: {name}"))
-					.collect::<Vec<_>>()
-					.join("\n")
-			);
-			let output_html = format!(
-				"<table><caption>Room list - page \
-				 {page}</caption>\n<tr><th>id</th>\t<th>members</th>\t<th>name</th></tr>\n{}</table>",
-				rooms
-					.iter()
-					.fold(String::new(), |mut output, (id, members, name)| {
-						writeln!(
-							output,
-							"<tr><td>{}</td>\t<td>{}</td>\t<td>{}</td></tr>",
-							escape_html(id.as_ref()),
-							members,
-							escape_html(name)
-						)
-						.unwrap();
-						output
-					})
-			);
-			Ok(RoomMessageEventContent::text_html(output_plain, output_html))
-		},
-	}
-}
diff --git a/src/service/admin/room/mod.rs b/src/service/admin/room/mod.rs
new file mode 100644
index 000000000..b4b7b2798
--- /dev/null
+++ b/src/service/admin/room/mod.rs
@@ -0,0 +1,160 @@
+use clap::Subcommand;
+use ruma::{events::room::message::RoomMessageEventContent, RoomId, RoomOrAliasId};
+
+use self::room_commands::list;
+use crate::Result;
+
+pub(crate) mod room_alias_commands;
+pub(crate) mod room_commands;
+pub(crate) mod room_directory_commands;
+pub(crate) mod room_moderation_commands;
+
+#[cfg_attr(test, derive(Debug))]
+#[derive(Subcommand)]
+pub(crate) enum RoomCommand {
+	/// - List all rooms the server knows about
+	List {
+		page: Option<usize>,
+	},
+
+	#[command(subcommand)]
+	/// - Manage moderation of remote or local rooms
+	Moderation(RoomModerationCommand),
+
+	#[command(subcommand)]
+	/// - Manage rooms' aliases
+	Alias(RoomAliasCommand),
+
+	#[command(subcommand)]
+	/// - Manage the room directory
+	Directory(RoomDirectoryCommand),
+}
+
+#[cfg_attr(test, derive(Debug))]
+#[derive(Subcommand)]
+pub(crate) enum RoomAliasCommand {
+	/// - Make an alias point to a room.
+	Set {
+		#[arg(short, long)]
+		/// Set the alias even if a room is already using it
+		force: bool,
+
+		/// The room id to set the alias on
+		room_id: Box<RoomId>,
+
+		/// The alias localpart to use (`alias`, not `#alias:servername.tld`)
+		room_alias_localpart: String,
+	},
+
+	/// - Remove an alias
+	Remove {
+		/// The alias localpart to remove (`alias`, not `#alias:servername.tld`)
+		room_alias_localpart: String,
+	},
+
+	/// - Show which room is using an alias
+	Which {
+		/// The alias localpart to look up (`alias`, not
+		/// `#alias:servername.tld`)
+		room_alias_localpart: String,
+	},
+
+	/// - List aliases currently being used
+	List {
+		/// If set, only list the aliases for this room
+		room_id: Option<Box<RoomId>>,
+	},
+}
+
+#[cfg_attr(test, derive(Debug))]
+#[derive(Subcommand)]
+pub(crate) enum RoomDirectoryCommand {
+	/// - Publish a room to the room directory
+	Publish {
+		/// The room id of the room to publish
+		room_id: Box<RoomId>,
+	},
+
+	/// - Unpublish a room to the room directory
+	Unpublish {
+		/// The room id of the room to unpublish
+		room_id: Box<RoomId>,
+	},
+
+	/// - List rooms that are published
+	List {
+		page: Option<usize>,
+	},
+}
+
+#[cfg_attr(test, derive(Debug))]
+#[derive(Subcommand)]
+pub(crate) enum RoomModerationCommand {
+	/// - Bans a room from local users joining and evicts all our local users
+	///   from the room. Also blocks any invites (local and remote) for the
+	///   banned room.
+	///
+	/// Server admins (users in the conduwuit admin room) will not be evicted
+	/// and server admins can still join the room. To evict admins too, use
+	/// --force (also ignores errors) To disable incoming federation of the
+	/// room, use --disable-federation
+	BanRoom {
+		#[arg(short, long)]
+		/// Evicts admins out of the room and ignores any potential errors when
+		/// making our local users leave the room
+		force: bool,
+
+		#[arg(long)]
+		/// Disables incoming federation of the room after banning and evicting
+		/// users
+		disable_federation: bool,
+
+		/// The room in the format of `!roomid:example.com` or a room alias in
+		/// the format of `#roomalias:example.com`
+		room: Box<RoomOrAliasId>,
+	},
+
+	/// - Bans a list of rooms (room IDs and room aliases) from a newline
+	///   delimited codeblock similar to `user deactivate-all`
+	BanListOfRooms {
+		#[arg(short, long)]
+		/// Evicts admins out of the room and ignores any potential errors when
+		/// making our local users leave the room
+		force: bool,
+
+		#[arg(long)]
+		/// Disables incoming federation of the room after banning and evicting
+		/// users
+		disable_federation: bool,
+	},
+
+	/// - Unbans a room to allow local users to join again
+	///
+	/// To re-enable incoming federation of the room, use --enable-federation
+	UnbanRoom {
+		#[arg(long)]
+		/// Enables incoming federation of the room after unbanning
+		enable_federation: bool,
+
+		/// The room in the format of `!roomid:example.com` or a room alias in
+		/// the format of `#roomalias:example.com`
+		room: Box<RoomOrAliasId>,
+	},
+
+	/// - List of all rooms we have banned
+	ListBannedRooms,
+}
+
+pub(crate) async fn process(command: RoomCommand, body: Vec<&str>) -> Result<RoomMessageEventContent> {
+	Ok(match command {
+		RoomCommand::Alias(command) => room_alias_commands::process(command, body).await?,
+
+		RoomCommand::Directory(command) => room_directory_commands::process(command, body).await?,
+
+		RoomCommand::Moderation(command) => room_moderation_commands::process(command, body).await?,
+
+		RoomCommand::List {
+			page,
+		} => list(body, page).await?,
+	})
+}
diff --git a/src/service/admin/room_alias.rs b/src/service/admin/room/room_alias_commands.rs
similarity index 84%
rename from src/service/admin/room_alias.rs
rename to src/service/admin/room/room_alias_commands.rs
index f16213445..516df0711 100644
--- a/src/service/admin/room_alias.rs
+++ b/src/service/admin/room/room_alias_commands.rs
@@ -1,46 +1,10 @@
 use std::fmt::Write as _;
 
-use clap::Subcommand;
-use ruma::{events::room::message::RoomMessageEventContent, RoomAliasId, RoomId};
+use ruma::{events::room::message::RoomMessageEventContent, RoomAliasId};
 
+use super::RoomAliasCommand;
 use crate::{service::admin::escape_html, services, Result};
 
-#[cfg_attr(test, derive(Debug))]
-#[derive(Subcommand)]
-pub(crate) enum RoomAliasCommand {
-	/// - Make an alias point to a room.
-	Set {
-		#[arg(short, long)]
-		/// Set the alias even if a room is already using it
-		force: bool,
-
-		/// The room id to set the alias on
-		room_id: Box<RoomId>,
-
-		/// The alias localpart to use (`alias`, not `#alias:servername.tld`)
-		room_alias_localpart: String,
-	},
-
-	/// - Remove an alias
-	Remove {
-		/// The alias localpart to remove (`alias`, not `#alias:servername.tld`)
-		room_alias_localpart: String,
-	},
-
-	/// - Show which room is using an alias
-	Which {
-		/// The alias localpart to look up (`alias`, not
-		/// `#alias:servername.tld`)
-		room_alias_localpart: String,
-	},
-
-	/// - List aliases currently being used
-	List {
-		/// If set, only list the aliases for this room
-		room_id: Option<Box<RoomId>>,
-	},
-}
-
 pub(crate) async fn process(command: RoomAliasCommand, _body: Vec<&str>) -> Result<RoomMessageEventContent> {
 	match command {
 		RoomAliasCommand::Set {
diff --git a/src/service/admin/room/room_commands.rs b/src/service/admin/room/room_commands.rs
new file mode 100644
index 000000000..f4964adfb
--- /dev/null
+++ b/src/service/admin/room/room_commands.rs
@@ -0,0 +1,59 @@
+use std::fmt::Write as _;
+
+use ruma::{events::room::message::RoomMessageEventContent, OwnedRoomId};
+
+use crate::{
+	service::admin::{escape_html, get_room_info, PAGE_SIZE},
+	services, Result,
+};
+
+pub(super) async fn list(_body: Vec<&str>, page: Option<usize>) -> Result<RoomMessageEventContent> {
+	// TODO: i know there's a way to do this with clap, but i can't seem to find it
+	let page = page.unwrap_or(1);
+	let mut rooms = services()
+		.rooms
+		.metadata
+		.iter_ids()
+		.filter_map(Result::ok)
+		.map(|id: OwnedRoomId| get_room_info(&id))
+		.collect::<Vec<_>>();
+	rooms.sort_by_key(|r| r.1);
+	rooms.reverse();
+
+	let rooms = rooms
+		.into_iter()
+		.skip(page.saturating_sub(1) * PAGE_SIZE)
+		.take(PAGE_SIZE)
+		.collect::<Vec<_>>();
+
+	if rooms.is_empty() {
+		return Ok(RoomMessageEventContent::text_plain("No more rooms."));
+	};
+
+	let output_plain = format!(
+		"Rooms:\n{}",
+		rooms
+			.iter()
+			.map(|(id, members, name)| format!("{id}\tMembers: {members}\tName: {name}"))
+			.collect::<Vec<_>>()
+			.join("\n")
+	);
+	let output_html = format!(
+		"<table><caption>Room list - page \
+		 {page}</caption>\n<tr><th>id</th>\t<th>members</th>\t<th>name</th></tr>\n{}</table>",
+		rooms
+			.iter()
+			.fold(String::new(), |mut output, (id, members, name)| {
+				writeln!(
+					output,
+					"<tr><td>{}</td>\t<td>{}</td>\t<td>{}</td></tr>",
+					escape_html(id.as_ref()),
+					members,
+					escape_html(name)
+				)
+				.unwrap();
+				output
+			})
+	);
+	Ok(RoomMessageEventContent::text_html(output_plain, output_html))
+}
diff --git a/src/service/admin/room_directory.rs b/src/service/admin/room/room_directory_commands.rs
similarity index 80%
rename from src/service/admin/room_directory.rs
rename to src/service/admin/room/room_directory_commands.rs
index 86dc03d61..bfdc7a60e 100644
--- a/src/service/admin/room_directory.rs
+++ b/src/service/admin/room/room_directory_commands.rs
@@ -1,47 +1,26 @@
 use std::fmt::Write as _;
 
-use clap::Subcommand;
-use ruma::{events::room::message::RoomMessageEventContent, OwnedRoomId, RoomId};
+use ruma::{events::room::message::RoomMessageEventContent, OwnedRoomId};
 
+use super::RoomDirectoryCommand;
 use crate::{
 	service::admin::{escape_html, get_room_info, PAGE_SIZE},
 	services, Result,
 };
 
-#[cfg_attr(test, derive(Debug))]
-#[derive(Subcommand)]
-pub(crate) enum RoomDirectoryCommand {
-	/// - Publish a room to the room directory
-	Publish {
-		/// The room id of the room to publish
-		room_id: Box<RoomId>,
-	},
-
-	/// - Unpublish a room to the room directory
-	Unpublish {
-		/// The room id of the room to unpublish
-		room_id: Box<RoomId>,
-	},
-
-	/// - List rooms that are published
-	List {
-		page: Option<usize>,
-	},
-}
-
 pub(crate) async fn process(command: RoomDirectoryCommand, _body: Vec<&str>) -> Result<RoomMessageEventContent> {
 	match command {
 		RoomDirectoryCommand::Publish {
 			room_id,
 		} => match services().rooms.directory.set_public(&room_id) {
 			Ok(()) => Ok(RoomMessageEventContent::text_plain("Room published")),
-			Err(err) => Ok(RoomMessageEventContent::text_plain(format!("Unable to update room: {}", err))),
+			Err(err) => Ok(RoomMessageEventContent::text_plain(format!("Unable to update room: {err}"))),
 		},
 		RoomDirectoryCommand::Unpublish {
 			room_id,
 		} => match services().rooms.directory.set_not_public(&room_id) {
 			Ok(()) => Ok(RoomMessageEventContent::text_plain("Room unpublished")),
-			Err(err) => Ok(RoomMessageEventContent::text_plain(format!("Unable to update room: {}", err))),
+			Err(err) => Ok(RoomMessageEventContent::text_plain(format!("Unable to update room: {err}"))),
 		},
 		RoomDirectoryCommand::List {
 			page,
diff --git a/src/service/admin/room_moderation.rs b/src/service/admin/room/room_moderation_commands.rs
similarity index 90%
rename from src/service/admin/room_moderation.rs
rename to src/service/admin/room/room_moderation_commands.rs
index 18a42a372..c02d8d16d 100644
--- a/src/service/admin/room_moderation.rs
+++ b/src/service/admin/room/room_moderation_commands.rs
@@ -1,75 +1,17 @@
 use std::fmt::Write as _;
 
-use clap::Subcommand;
 use ruma::{
 	events::room::message::RoomMessageEventContent, OwnedRoomId, OwnedUserId, RoomAliasId, RoomId, RoomOrAliasId,
 };
 use tracing::{debug, error, info, warn};
 
+use super::RoomModerationCommand;
 use crate::{
 	api::client_server::{get_alias_helper, leave_room},
 	service::admin::{escape_html, Service},
 	services, Result,
 };
 
-#[cfg_attr(test, derive(Debug))]
-#[derive(Subcommand)]
-pub(crate) enum RoomModerationCommand {
-	/// - Bans a room from local users joining and evicts all our local users
-	///   from the room. Also blocks any invites (local and remote) for the
-	///   banned room.
-	///
-	/// Server admins (users in the conduwuit admin room) will not be evicted
-	/// and server admins can still join the room. To evict admins too, use
-	/// --force (also ignores errors) To disable incoming federation of the
-	/// room, use --disable-federation
-	BanRoom {
-		#[arg(short, long)]
-		/// Evicts admins out of the room and ignores any potential errors when
-		/// making our local users leave the room
-		force: bool,
-
-		#[arg(long)]
-		/// Disables incoming federation of the room after banning and evicting
-		/// users
-		disable_federation: bool,
-
-		/// The room in the format of `!roomid:example.com` or a room alias in
-		/// the format of `#roomalias:example.com`
-		room: Box<RoomOrAliasId>,
-	},
-
-	/// - Bans a list of rooms (room IDs and room aliases) from a newline
-	///   delimited codeblock similar to `user deactivate-all`
-	BanListOfRooms {
-		#[arg(short, long)]
-		/// Evicts admins out of the room and ignores any potential errors when
-		/// making our local users leave the room
-		force: bool,
-
-		#[arg(long)]
-		/// Disables incoming federation of the room after banning and evicting
-		/// users
-		disable_federation: bool,
-	},
-
-	/// - Unbans a room to allow local users to join again
-	///
-	/// To re-enable incoming federation of the room, use --enable-federation
-	UnbanRoom {
-		#[arg(long)]
-		/// Enables incoming federation of the room after unbanning
-		enable_federation: bool,
-
-		/// The room in the format of `!roomid:example.com` or a room alias in
-		/// the format of `#roomalias:example.com`
-		room: Box<RoomOrAliasId>,
-	},
-
-	/// - List of all rooms we have banned
-	ListBannedRooms,
-}
-
 pub(crate) async fn process(command: RoomModerationCommand, body: Vec<&str>) -> Result<RoomMessageEventContent> {
 	match command {
 		RoomModerationCommand::BanRoom {
-- 
GitLab