From 1deaf99409a8c7f99daf248f3ef063e4257d9455 Mon Sep 17 00:00:00 2001 From: timedout Date: Mon, 5 Oct 2026 05:08:36 +0100 Subject: [PATCH] feat(admin): List users endpoint --- Cargo.toml | 3 + src/api/admin/mod.rs | 1 + src/api/admin/rooms/ban.rs | 2 +- src/api/admin/rooms/list.rs | 2 +- src/api/admin/users/list.rs | 99 +++++++++++++++++++ src/api/admin/users/mod.rs | 1 + src/api/router.rs | 3 +- src/ruminuwuity/Cargo.toml | 1 + src/ruminuwuity/admin/continuwuity/mod.rs | 1 + .../admin/continuwuity/rooms/ban.rs | 7 +- .../admin/continuwuity/rooms/list.rs | 7 +- .../admin/continuwuity/users/list.rs | 84 ++++++++++++++++ .../admin/continuwuity/users/mod.rs | 1 + src/service/users/account.rs | 2 +- 14 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 src/api/admin/users/list.rs create mode 100644 src/api/admin/users/mod.rs create mode 100644 src/ruminuwuity/admin/continuwuity/users/list.rs create mode 100644 src/ruminuwuity/admin/continuwuity/users/mod.rs diff --git a/Cargo.toml b/Cargo.toml index bd605174f..159b9087b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -659,6 +659,9 @@ default-features = false [workspace.dependencies.ruminuwuity] package = "ruminuwuity" path = "src/ruminuwuity" +features = [ + "unstable-msc4484" +] ############################################################################### # diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index 5d505fa82..ed9adcb7b 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -1 +1,2 @@ pub mod rooms; +pub mod users; diff --git a/src/api/admin/rooms/ban.rs b/src/api/admin/rooms/ban.rs index 384934644..b493f9a9e 100644 --- a/src/api/admin/rooms/ban.rs +++ b/src/api/admin/rooms/ban.rs @@ -6,7 +6,7 @@ use ruminuwuity::admin::continuwuity::rooms; use crate::{Ruma, client::leave_room}; -/// # `PUT /_continuwuity/admin/rooms/{roomID}/ban` +/// # `PUT /_continuwuity/admin/v1/rooms/{roomID}/ban` /// /// Bans or unbans a room. pub(crate) async fn ban_room( diff --git a/src/api/admin/rooms/list.rs b/src/api/admin/rooms/list.rs index af81f2eec..2f4079dc4 100644 --- a/src/api/admin/rooms/list.rs +++ b/src/api/admin/rooms/list.rs @@ -6,7 +6,7 @@ use ruminuwuity::admin::continuwuity::rooms; use crate::Ruma; -/// # `GET /_continuwuity/admin/rooms/list` +/// # `GET /_continuwuity/admin/v1/rooms/list` /// /// Lists all rooms known to this server, excluding banned ones. pub(crate) async fn list_rooms( diff --git a/src/api/admin/users/list.rs b/src/api/admin/users/list.rs new file mode 100644 index 000000000..d05d5d3b3 --- /dev/null +++ b/src/api/admin/users/list.rs @@ -0,0 +1,99 @@ +use std::collections::BTreeMap; + +use axum::extract::State; +use conduwuit::{ + Err, + utils::{TryFutureExtExt, stream::WidebandExt}, +}; +use futures::StreamExt; +use ruminuwuity::admin::continuwuity::users::list::v1::{QueryFilter, Request, Response, User}; +use service::users::AccountStatus; + +use crate::router::Ruma; + +/// # `PUT /_continuwuity/admin/v1/users` +/// +/// Lists users on this homeserver +pub(crate) async fn list_users( + 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); + let offset = limit.saturating_mul(body.page.unwrap_or_default()); + let users = services + .users + .stream_local_users() + .wide_filter_map(|user_id| { + let body = &body; + async move { + let status = services.users.status(&user_id).await; + assert_ne!( + status, + AccountStatus::NotFound, + "Not found users should not appear in stream_local_users" + ); + let (deactivated, locked, suspended, admin) = tokio::join!( + std::future::ready(status == AccountStatus::Deactivated), + services.users.is_locked(&user_id).unwrap_or_default(), + services.users.is_suspended(&user_id).unwrap_or_default(), + services.users.is_admin(&user_id), + ); + // Exclude deactivated accounts by default + if !deactivated && body.active == Some(QueryFilter::Exclude) { + return None; + } + if deactivated + && body + .deactivated + .as_ref() + .is_none_or(|d| *d == QueryFilter::Include) + { + return None; + } + // Exclude locked accounts by default + if locked && body.locked != Some(QueryFilter::Include) { + return None; + } + // Include suspended accounts by default + if suspended && body.suspended == Some(QueryFilter::Exclude) { + return None; + } + // Include admins by default + if admin && body.admin == Some(QueryFilter::Exclude) { + return None; + } + + Some((user_id, deactivated, suspended, locked, admin)) + } + }) + .skip(offset) + .wide_then(|(user_id, deactivated, suspended, locked, admin)| async move { + let (displayname, avatar_url) = tokio::join!( + services.users.displayname(&user_id).ok(), + services.users.avatar_url(&user_id).ok(), + ); + let user = User { + displayname, + avatar_url, + suspended, + locked, + deactivated, + admin, + }; + + (user_id, user) + }) + .take(limit) + .collect::>() + .await; + + Ok(Response::new(users)) +} diff --git a/src/api/admin/users/mod.rs b/src/api/admin/users/mod.rs new file mode 100644 index 000000000..d17e233fb --- /dev/null +++ b/src/api/admin/users/mod.rs @@ -0,0 +1 @@ +pub mod list; diff --git a/src/api/router.rs b/src/api/router.rs index 1912f096a..664af3232 100644 --- a/src/api/router.rs +++ b/src/api/router.rs @@ -197,7 +197,8 @@ pub fn build(router: Router, state: State) -> Router { .merge(client::oauth::router(state)) .route("/_continuwuity/server_version", get(client::continuwuity_server_version)) .ruma_route(&admin::rooms::ban::ban_room) - .ruma_route(&admin::rooms::list::list_rooms); + .ruma_route(&admin::rooms::list::list_rooms) + .ruma_route(&admin::users::list::list_users); if config.allow_federation { router = router diff --git a/src/ruminuwuity/Cargo.toml b/src/ruminuwuity/Cargo.toml index 0bdcb2f90..ee6c68a0c 100644 --- a/src/ruminuwuity/Cargo.toml +++ b/src/ruminuwuity/Cargo.toml @@ -18,6 +18,7 @@ server = [] unstable-exhaustive-types = [] unstable-msc3202 = [] unstable-msc4203 = [] +unstable-msc4484 = ["ruma/unstable-msc4484"] [dependencies] assign.workspace = true diff --git a/src/ruminuwuity/admin/continuwuity/mod.rs b/src/ruminuwuity/admin/continuwuity/mod.rs index 5d505fa82..ed9adcb7b 100644 --- a/src/ruminuwuity/admin/continuwuity/mod.rs +++ b/src/ruminuwuity/admin/continuwuity/mod.rs @@ -1 +1,2 @@ pub mod rooms; +pub mod users; diff --git a/src/ruminuwuity/admin/continuwuity/rooms/ban.rs b/src/ruminuwuity/admin/continuwuity/rooms/ban.rs index 5600ab0df..4897c1771 100644 --- a/src/ruminuwuity/admin/continuwuity/rooms/ban.rs +++ b/src/ruminuwuity/admin/continuwuity/rooms/ban.rs @@ -1,7 +1,7 @@ pub mod v1 { use ruma::{ OwnedRoomAliasId, OwnedRoomId, OwnedUserId, - api::{auth_scheme::AccessToken, request, response}, + api::{OAuthClientScope, auth_scheme::AccessToken, request, response}, metadata, }; @@ -10,8 +10,9 @@ pub mod v1 { rate_limited: false, authentication: AccessToken, history: { - 1.0 => "/_continuwuity/admin/rooms/{room_id}/ban", - } + 1.0 => "/_continuwuity/admin/v1/rooms/{room_id}/ban", + }, + required_client_scopes: [OAuthClientScope::ServerAdministration] } #[request] diff --git a/src/ruminuwuity/admin/continuwuity/rooms/list.rs b/src/ruminuwuity/admin/continuwuity/rooms/list.rs index 2552d6a21..5dcf6b5f3 100644 --- a/src/ruminuwuity/admin/continuwuity/rooms/list.rs +++ b/src/ruminuwuity/admin/continuwuity/rooms/list.rs @@ -1,7 +1,7 @@ pub mod v1 { use ruma::{ OwnedRoomId, - api::{auth_scheme::AccessToken, request, response}, + api::{OAuthClientScope, auth_scheme::AccessToken, request, response}, metadata, }; @@ -10,8 +10,9 @@ pub mod v1 { rate_limited: false, authentication: AccessToken, history: { - 1.0 => "/_continuwuity/admin/rooms/list", - } + 1.0 => "/_continuwuity/admin/v1/rooms/list", + }, + required_client_scopes: [OAuthClientScope::ServerAdministration] } #[request] diff --git a/src/ruminuwuity/admin/continuwuity/users/list.rs b/src/ruminuwuity/admin/continuwuity/users/list.rs new file mode 100644 index 000000000..dcc09ded4 --- /dev/null +++ b/src/ruminuwuity/admin/continuwuity/users/list.rs @@ -0,0 +1,84 @@ +pub mod v1 { + use std::collections::BTreeMap; + + use ruma::{ + OwnedMxcUri, OwnedUserId, + api::{OAuthClientScope, auth_scheme::AccessToken, request, response}, + metadata, + }; + use serde::{Deserialize, Serialize}; + + metadata! { + method: GET, + rate_limited: false, + authentication: AccessToken, + history: { + 1.0 => "/_continuwuity/admin/v1/users", + }, + required_client_scopes: [OAuthClientScope::ServerAdministration] + } + + #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Copy)] + pub enum QueryFilter { + /// Always include entities matching this criteria. + #[serde(rename = "include")] + Include, + /// Never include entities matching this criteria. + #[serde(rename = "exclude")] + Exclude, + } + + #[derive(Default)] + #[request] + #[serde(default)] + pub struct Request { + #[ruma_api(query)] + #[serde(skip_serializing_if = "Option::is_none")] + pub active: Option, + #[ruma_api(query)] + #[serde(skip_serializing_if = "Option::is_none")] + pub deactivated: Option, + #[ruma_api(query)] + #[serde(skip_serializing_if = "Option::is_none")] + pub suspended: Option, + #[ruma_api(query)] + #[serde(skip_serializing_if = "Option::is_none")] + pub locked: Option, + #[ruma_api(query)] + #[serde(skip_serializing_if = "Option::is_none")] + pub admin: Option, + #[ruma_api(query)] + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[ruma_api(query)] + #[serde(skip_serializing_if = "Option::is_none")] + pub page: Option, + } + + #[derive(Default, Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] + pub struct User { + #[serde(skip_serializing_if = "Option::is_none")] + pub displayname: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + #[serde(skip_serializing_if = "ruma::serde::is_default")] + pub suspended: bool, + #[serde(skip_serializing_if = "ruma::serde::is_default")] + pub locked: bool, + #[serde(skip_serializing_if = "ruma::serde::is_default")] + pub deactivated: bool, + #[serde(skip_serializing_if = "ruma::serde::is_default")] + pub admin: bool, + } + + #[response] + pub struct Response { + #[ruma_api(body)] + pub users: BTreeMap, + } + + impl Response { + #[must_use] + pub fn new(users: BTreeMap) -> Self { Self { users } } + } +} diff --git a/src/ruminuwuity/admin/continuwuity/users/mod.rs b/src/ruminuwuity/admin/continuwuity/users/mod.rs new file mode 100644 index 000000000..d17e233fb --- /dev/null +++ b/src/ruminuwuity/admin/continuwuity/users/mod.rs @@ -0,0 +1 @@ +pub mod list; diff --git a/src/service/users/account.rs b/src/service/users/account.rs index 39cadab32..01704371d 100644 --- a/src/service/users/account.rs +++ b/src/service/users/account.rs @@ -37,7 +37,7 @@ pub enum AccessTokenStatus { } /// The status of a user account. -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq, Debug, Eq)] pub enum AccountStatus { NotFound, Active,