diff --git a/src/api/client/admin/site/rooms/list.rs b/src/api/client/admin/site/rooms/list.rs index 48594a40d..93a1dcc2b 100644 --- a/src/api/client/admin/site/rooms/list.rs +++ b/src/api/client/admin/site/rooms/list.rs @@ -1,18 +1,36 @@ use axum::extract::State; -use conduwuit::{Err, Result}; +use conduwuit::{ + Err, Event, Result, + utils::stream::{BroadbandExt, WidebandExt}, +}; use futures::StreamExt; -use ruma::OwnedRoomId; +use ruma::{ + OwnedRoomId, + events::{ + StateEventType, + room::{ + create::RoomCreateEventContent, + encryption::PossiblyRedactedRoomEncryptionEventContent, + }, + }, +}; use ruminuwuity::admin::continuwuity::rooms; +use tokio::join; use crate::Ruma; -/// # `GET /_continuwuity/admin/v1/rooms/list` +/// # `GET /_continuwuity/admin/rooms` /// -/// Lists all rooms known to this server, excluding banned ones. -pub(crate) async fn list_rooms( +/// Lists all room IDs known to this server, excluding banned ones. +/// +/// This is the legacy version of the endpoint, which does not support +/// pagination or including banned rooms. It is recommended to use the +/// `/v1/rooms` endpoint instead. This endpoint may be removed in a future +/// release. +pub(crate) async fn legacy_list_rooms_route( State(services): State, - body: Ruma, -) -> Result { + body: Ruma, +) -> Result { let sender_user = body.identity.sender_user(); if !services.users.is_admin(sender_user).await { return Err!(Request(Forbidden("Only server administrators can use this endpoint"))); @@ -32,5 +50,127 @@ pub(crate) async fn list_rooms( .collect() .await; rooms.sort(); + Ok(rooms::list::unstable::Response::new(rooms)) +} + +/// # `GET /_continuwuity/admin/v1/rooms` +/// +/// Lists rooms known to this server. +pub(crate) async fn list_rooms_route( + State(services): State, + body: Ruma, +) -> Result { + let sender_user = body.sender_user(); + if !services.users.is_admin(sender_user).await { + return Err!(Request(Forbidden("Only server administrators can use this endpoint"))); + } + + let include_banned_rooms = body.include_banned_rooms; + let rooms = services + .rooms + .metadata + .iter_ids() + .wide_filter_map(|room_id| async move { + if include_banned_rooms || !services.rooms.metadata.is_banned(&room_id).await { + Some(room_id.clone()) + } else { + None + } + }) + .skip(body.offset.unwrap_or_default()) + .take(body.limit.unwrap_or(100).min(100)) + .broad_filter_map(|room_id| async move { + let ( + banned, + disabled, + member_count, + local_member_count, + resident_server_count, + published, + create_event, + encryption_event, + name_event, + topic_event, + canonical_alias_event, + join_rules_event, + history_visibility_event, + ) = join!( + services.rooms.metadata.is_banned(&room_id), + services.rooms.metadata.is_disabled(&room_id), + services.rooms.state_cache.room_joined_count(&room_id), + services + .rooms + .state_cache + .active_local_users_in_room(&room_id) + .count(), + services.rooms.state_cache.room_servers(&room_id).count(), + services.rooms.directory.is_public_room(&room_id), + services.rooms.state_accessor.room_state_get( + &room_id, + &StateEventType::RoomCreate, + "" + ), + services + .rooms + .state_accessor + .room_state_get_content::( + &room_id, + &StateEventType::RoomEncryption, + "" + ), + services.rooms.state_accessor.room_state_get_content( + &room_id, + &StateEventType::RoomName, + "" + ), + services.rooms.state_accessor.room_state_get_content( + &room_id, + &StateEventType::RoomTopic, + "" + ), + services.rooms.state_accessor.room_state_get_content( + &room_id, + &StateEventType::RoomCanonicalAlias, + "" + ), + services.rooms.state_accessor.room_state_get_content( + &room_id, + &StateEventType::RoomJoinRules, + "" + ), + services.rooms.state_accessor.room_state_get_content( + &room_id, + &StateEventType::RoomHistoryVisibility, + "" + ), + ); + let Ok(create_event) = create_event else { + return None; + }; + let create_content = create_event + .get_content::() + .expect("m.room.create content must be valid"); + Some(rooms::list::v1::MinimalRoomInfo { + room_id, + banned, + disabled, + member_count: usize::try_from(member_count.unwrap_or_default()) + .expect("u64 should fit in usize"), + local_member_count, + resident_server_count, + creators: vec![create_event.sender], + encrypted: encryption_event.is_ok_and(|c| c.algorithm.is_some()), + federated: create_content.federate, + published, + version: create_content.room_version, + name: name_event.unwrap_or(None), + topic: topic_event.unwrap_or(None), + canonical_alias: canonical_alias_event.unwrap_or(None), + join_rules: join_rules_event.unwrap_or(None), + history_visibility: history_visibility_event.unwrap_or(None), + }) + }) + .collect() + .await; Ok(rooms::list::v1::Response::new(rooms)) } diff --git a/src/api/client/admin/site/rooms/mod.rs b/src/api/client/admin/site/rooms/mod.rs index e41d4e714..1cccece98 100644 --- a/src/api/client/admin/site/rooms/mod.rs +++ b/src/api/client/admin/site/rooms/mod.rs @@ -2,4 +2,4 @@ mod ban; mod list; pub(crate) use ban::ban_room; -pub(crate) use list::list_rooms; +pub(crate) use list::*; diff --git a/src/api/router.rs b/src/api/router.rs index 0b295e06e..c95254543 100644 --- a/src/api/router.rs +++ b/src/api/router.rs @@ -6,13 +6,13 @@ mod response; use std::str::FromStr; use axum::{ + Router, response::{IntoResponse, Redirect}, routing::{any, get, post}, - Router, }; use conduwuit::err; pub(super) use conduwuit_service::state::State; -use http::{uri, Uri}; +use http::{Uri, uri}; use self::handler::RouterExt; pub(super) use self::{args::Args as Ruma, auth::ClientIdentity, response::RumaResponse}; @@ -284,7 +284,8 @@ pub fn build(router: Router, state: State) -> Router { router = router .ruma_route(&admin_api::users::list_users_route) .ruma_route(&admin_api::rooms::ban_room) - .ruma_route(&admin_api::rooms::list_rooms); + .ruma_route(&admin_api::rooms::legacy_list_rooms_route) + .ruma_route(&admin_api::rooms::list_rooms_route); }; router diff --git a/src/ruminuwuity/admin/continuwuity/rooms/list.rs b/src/ruminuwuity/admin/continuwuity/rooms/list.rs index 75aafc996..10871d8ab 100644 --- a/src/ruminuwuity/admin/continuwuity/rooms/list.rs +++ b/src/ruminuwuity/admin/continuwuity/rooms/list.rs @@ -1,4 +1,4 @@ -pub mod v1 { +pub mod unstable { use ruma::{ OwnedRoomId, api::{auth_scheme::AccessToken, request, response}, @@ -10,8 +10,7 @@ pub mod v1 { rate_limited: false, authentication: AccessToken, history: { - unstable("org.continuwuity.admin") => "/_continuwuity/admin/rooms/list", - 1.0 => "/_continuwuity/admin/v1/rooms", + unstable => "/_continuwuity/admin/rooms/list", } } @@ -35,3 +34,127 @@ pub mod v1 { pub fn new(rooms: Vec) -> Self { Self { rooms } } } } + +pub mod v1 { + use ruma::{ + OwnedRoomId, OwnedUserId, RoomVersionId, + api::{auth_scheme::AccessToken, request, response}, + events::room::{ + canonical_alias::PossiblyRedactedRoomCanonicalAliasEventContent, + history_visibility::PossiblyRedactedRoomHistoryVisibilityEventContent, + join_rules::PossiblyRedactedRoomJoinRulesEventContent, + name::PossiblyRedactedRoomNameEventContent, + topic::PossiblyRedactedRoomTopicEventContent, + }, + metadata, + serde::{default_true, is_default}, + }; + + metadata! { + method: GET, + rate_limited: false, + authentication: AccessToken, + history: { + 1.0 => "/_continuwuity/admin/v1/rooms", + } + } + + #[request] + #[derive(Default)] + pub struct Request { + /// The maximum number of results to return in this page. Maximum (and + /// default) is 100. + #[ruma_api(query)] + #[serde(default, skip_serializing_if = "is_default")] + pub limit: Option, + + /// The number of results to skip over before returning results. Default + /// is 0. + #[ruma_api(query)] + #[serde(default, skip_serializing_if = "is_default")] + pub offset: Option, + + /// If true, includes banned rooms in the response. + #[ruma_api(query)] + #[serde(default, skip_serializing_if = "is_default")] + pub include_banned_rooms: bool, + } + + #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] + pub struct MinimalRoomInfo { + /// The room's unique ID. + pub room_id: OwnedRoomId, + /// If true, this room is banned, and cannot be joined by non-admins. + #[serde(default, skip_serializing_if = "is_default")] + pub banned: bool, + /// If true, this room has federation disabled, but can still be locally + /// used. + #[serde(default, skip_serializing_if = "is_default")] + pub disabled: bool, + /// The total number of joined members in this room. + #[serde(default, skip_serializing_if = "is_default")] + pub member_count: usize, + /// The total number of joined members in this room that are local to + /// this server. + #[serde(default, skip_serializing_if = "is_default")] + pub local_member_count: usize, + /// The number of unique homeservers currently joined to this room. + #[serde(default, skip_serializing_if = "is_default")] + pub resident_server_count: usize, + /// The users who created this room. + /// + /// The first entry is always the sender of the `m.room.create` event. + /// Any entries thereafter are additional creators in v12+ rooms. An + /// empty vec indicates the room is not known. + #[serde(default, skip_serializing_if = "is_default")] + pub creators: Vec, + /// If true, this room has encryption enabled. + #[serde(default, skip_serializing_if = "is_default")] + pub encrypted: bool, + /// If true, this room is allowed to be federated (`m.federate` is not + /// `false` in `m.room.create`). + #[serde(default = "default_true", skip_serializing_if = "is_default")] + pub federated: bool, + /// If true, this room is published to this server's room directory. + #[serde(default, skip_serializing_if = "is_default")] + pub published: bool, + /// The version of the room. + pub version: RoomVersionId, + /// The event content for the `m.room.name` event, if any is present. + /// May be redacted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// The event content for the `m.room.topic` event, if any is present. + /// May be redacted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub topic: Option, + /// The event content for the `m.room.canonical_alias` event, if any is + /// present. May be redacted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canonical_alias: Option, + /// The event content for the `m.room.join_rules` event, if any is + /// present. May be redacted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub join_rules: Option, + /// The event content for the `m.room.history_visibility` event, if any + /// is present. May be redacted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub history_visibility: Option, + } + + #[response] + pub struct Response { + /// A list of rooms known to this server. + pub rooms: Vec, + } + + impl Request { + #[must_use] + pub fn new() -> Self { Self::default() } + } + + impl Response { + #[must_use] + pub fn new(rooms: Vec) -> Self { Self { rooms } } + } +}