feat(admin): List users endpoint

This commit is contained in:
timedout committed 2026-10-05 05:08:36 +01:00
1 parent 1e34bd2ed9
commit 1deaf99409
14 files changed
+204 -10

No files matched your search

+3
View File
@@ -659,6 +659,9 @@ default-features = false
[workspace.dependencies.ruminuwuity]
package = "ruminuwuity"
path = "src/ruminuwuity"
features = [
"unstable-msc4484"
]
###############################################################################
#
+1
View File
@@ -1 +1,2 @@
pub mod rooms;
pub mod users;
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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(
+99
View File
@@ -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<crate::State>,
body: Ruma<Request>,
) -> conduwuit::Result<Response> {
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::<BTreeMap<_, _>>()
.await;
Ok(Response::new(users))
}
+1
View File
@@ -0,0 +1 @@
pub mod list;
+2 -1
View File
@@ -197,7 +197,8 @@ pub fn build(router: Router<State>, state: State) -> Router<State> {
.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
+1
View File
@@ -18,6 +18,7 @@ server = []
unstable-exhaustive-types = []
unstable-msc3202 = []
unstable-msc4203 = []
unstable-msc4484 = ["ruma/unstable-msc4484"]
[dependencies]
assign.workspace = true
@@ -1 +1,2 @@
pub mod rooms;
pub mod users;
@@ -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]
@@ -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]
@@ -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<QueryFilter>,
#[ruma_api(query)]
#[serde(skip_serializing_if = "Option::is_none")]
pub deactivated: Option<QueryFilter>,
#[ruma_api(query)]
#[serde(skip_serializing_if = "Option::is_none")]
pub suspended: Option<QueryFilter>,
#[ruma_api(query)]
#[serde(skip_serializing_if = "Option::is_none")]
pub locked: Option<QueryFilter>,
#[ruma_api(query)]
#[serde(skip_serializing_if = "Option::is_none")]
pub admin: Option<QueryFilter>,
#[ruma_api(query)]
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
#[ruma_api(query)]
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<usize>,
}
#[derive(Default, Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
pub struct User {
#[serde(skip_serializing_if = "Option::is_none")]
pub displayname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<OwnedMxcUri>,
#[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<OwnedUserId, User>,
}
impl Response {
#[must_use]
pub fn new(users: BTreeMap<OwnedUserId, User>) -> Self { Self { users } }
}
}
@@ -0,0 +1 @@
pub mod list;
+1 -1
View File
@@ -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,