diff --git a/src/api/admin/users/mod.rs b/src/api/admin/users/mod.rs index fa6613e51..69cc0734e 100644 --- a/src/api/admin/users/mod.rs +++ b/src/api/admin/users/mod.rs @@ -1,2 +1,3 @@ pub mod create; pub mod list; +pub mod rooms; diff --git a/src/api/admin/users/rooms.rs b/src/api/admin/users/rooms.rs new file mode 100644 index 000000000..d3f329ccd --- /dev/null +++ b/src/api/admin/users/rooms.rs @@ -0,0 +1,134 @@ +use axum::extract::State; +use conduwuit::{ + Err, Event, at, + utils::{IterStream, ReadyExt, TryFutureExtExt, stream::WidebandExt}, +}; +use futures::StreamExt; +use ruma::events::room::member::MembershipState; +use ruminuwuity::admin::continuwuity::users::rooms::v1::{Request, Response, RoomInfo}; +use serde::Deserialize; + +use crate::router::Ruma; + +/// # `GET /_continuwuity/admin/v1/users/{user_id}/rooms` +/// +/// Lists the memberships of a user on this server. +pub(crate) async fn list_user_rooms( + State(services): State, + body: Ruma, +) -> conduwuit::Result { + if !services + .users + .is_admin(body.identity.expect_sender_user()?) + .await + { + return Err!(Request(Forbidden("Only server administrators can use this endpoint"))); + } + + let limit = body.limit.unwrap_or(100).try_into()?; + let offset = body + .limit + .unwrap_or(100) + .saturating_mul(body.page.unwrap_or_default()) + .try_into()?; + + let room_ids: Vec<_> = match body.state { + | MembershipState::Join => + services + .rooms + .state_cache + .rooms_joined(&body.user_id) + .skip(offset) + .take(limit) + .collect() + .await, + | MembershipState::Invite => + services + .rooms + .state_cache + .rooms_invited(&body.user_id) + .skip(offset) + .take(limit) + .map(at!(0)) + .collect() + .await, + | MembershipState::Knock => + services + .rooms + .state_cache + .rooms_knocked(&body.user_id) + .skip(offset) + .take(limit) + .map(at!(0)) + .collect() + .await, + | MembershipState::Leave => + services + .rooms + .state_cache + .rooms_left(&body.user_id) + .skip(offset) + .take(limit) + .ready_filter_map(|(room_id, pdu)| { + pdu.is_some_and(|pdu| { + pdu.get_content::() + .expect("membership PDU must have a membership field") + .membership + == MembershipState::Leave + }) + .then_some(room_id) + }) + .collect() + .await, + | MembershipState::Ban => + services + .rooms + .state_cache + .rooms_left(&body.user_id) + .skip(offset) + .take(limit) + .ready_filter_map(|(room_id, pdu)| { + pdu.is_some_and(|pdu| { + pdu.get_content::() + .expect("membership PDU must have a membership field") + .membership + == MembershipState::Ban + }) + .then_some(room_id) + }) + .collect() + .await, + | _ => return Err!(Request(InvalidParam("Unknown membership kind: {:?}", body.state))), + }; + + let resp = room_ids + .into_iter() + .stream() + .wide_then(|room_id| async move { + let (name, avatar, alias, members) = tokio::join!( + services.rooms.state_accessor.get_name(&room_id).ok(), + services.rooms.state_accessor.get_avatar(&room_id), + services + .rooms + .state_accessor + .get_canonical_alias(&room_id) + .ok(), + services.rooms.state_cache.room_members(&room_id).count(), + ); + (room_id, RoomInfo { + name, + avatar_url: avatar.into_option().and_then(|a| a.url), + canonical_alias: alias, + member_count: members.try_into().unwrap_or(0), + }) + }) + .collect() + .await; + + Ok(Response::new(resp)) +} + +#[derive(Deserialize)] +struct ExtractMembership { + pub membership: MembershipState, +} diff --git a/src/api/router.rs b/src/api/router.rs index 022c98d5b..fae54db3d 100644 --- a/src/api/router.rs +++ b/src/api/router.rs @@ -199,7 +199,8 @@ pub fn build(router: Router, state: State) -> Router { .ruma_route(&admin::rooms::ban::ban_room) .ruma_route(&admin::rooms::list::list_rooms) .ruma_route(&admin::users::list::list_users) - .ruma_route(&admin::users::create::create_user); + .ruma_route(&admin::users::create::create_user) + .ruma_route(&admin::users::rooms::list_user_rooms); if config.allow_federation { router = router diff --git a/src/ruminuwuity/admin/continuwuity/users/mod.rs b/src/ruminuwuity/admin/continuwuity/users/mod.rs index fa6613e51..69cc0734e 100644 --- a/src/ruminuwuity/admin/continuwuity/users/mod.rs +++ b/src/ruminuwuity/admin/continuwuity/users/mod.rs @@ -1,2 +1,3 @@ pub mod create; pub mod list; +pub mod rooms; diff --git a/src/ruminuwuity/admin/continuwuity/users/rooms.rs b/src/ruminuwuity/admin/continuwuity/users/rooms.rs new file mode 100644 index 000000000..5717ef1cd --- /dev/null +++ b/src/ruminuwuity/admin/continuwuity/users/rooms.rs @@ -0,0 +1,66 @@ +pub mod v1 { + use std::collections::BTreeMap; + + use ruma::{ + OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, + api::{OAuthClientScope, auth_scheme::AccessToken, request, response}, + events::room::member::MembershipState, + metadata, + }; + use serde::{Deserialize, Serialize}; + + metadata! { + method: GET, + rate_limited: false, + authentication: AccessToken, + history: { + 1.0 => "/_continuwuity/admin/v1/users/{user_id}/rooms", + }, + required_client_scopes: [OAuthClientScope::ServerAdministration] + } + + #[request] + pub struct Request { + #[ruma_api(path)] + pub user_id: OwnedUserId, + + pub state: MembershipState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page: Option, + } + + #[derive(Debug, Deserialize, Serialize, Default, Clone)] + pub struct RoomInfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canonical_alias: Option, + pub member_count: u32, + } + + #[response] + pub struct Response { + pub rooms: BTreeMap, + } + + impl Request { + #[must_use] + pub fn new( + user_id: OwnedUserId, + state: MembershipState, + limit: Option, + page: Option, + ) -> Self { + Self { user_id, state, limit, page } + } + } + + impl Response { + #[must_use] + pub fn new(rooms: BTreeMap) -> Self { Self { rooms } } + } +}