Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pubky-homeserver/src/admin_server/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::time::Duration;

use super::routes::{
dav_handler, delete_entry,
disable_users::{disable_user, enable_user},
disable_users::{disable_user, enable_user, list_disabled_users},
generate_signup_token, info, root,
};
use super::trace::with_trace_layer;
Expand All @@ -30,6 +30,7 @@ fn create_protected_router(password: &str) -> Router<AppState> {
.route("/webdav/{*entry_path}", delete(delete_entry::delete_entry))
.route("/users/{pubkey}/disable", post(disable_user))
.route("/users/{pubkey}/enable", post(enable_user))
.route("/users/disabled", get(list_disabled_users))
.layer(AdminAuthLayer::new(password.to_string()))
}

Expand Down
130 changes: 128 additions & 2 deletions pubky-homeserver/src/admin_server/routes/disable_users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ use crate::{
shared::{HttpError, HttpResult, Z32Pubkey},
};
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use pubky_common::crypto::PublicKey;
use serde::{Deserialize, Serialize};

/// Delete a single entry from the database.
///
Expand Down Expand Up @@ -67,12 +70,62 @@ pub async fn enable_user(
Ok((StatusCode::OK, "Ok"))
}

#[derive(Debug, Deserialize)]
pub struct ListDisabledUsersQuery {
limit: Option<u16>,
cursor: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct DisabledUser {
pubkey: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ListDisabledUsersResponse {
items: Vec<DisabledUser>,
next_cursor: Option<String>,
}

/// List disabled users with cursor-based pagination.
///
/// # Errors
///
/// - `400` if `cursor` is invalid.
pub async fn list_disabled_users(
State(state): State<AppState>,
Query(query): Query<ListDisabledUsersQuery>,
) -> HttpResult<(StatusCode, Json<ListDisabledUsersResponse>)> {
let cursor = query
.cursor
.as_deref()
.map(PublicKey::try_from_z32)
.transpose()
.map_err(|_| HttpError::bad_request("Invalid cursor"))?;

let page =
UserRepository::list_disabled(query.limit, cursor, &mut state.sql_db.pool().into()).await?;

let body = ListDisabledUsersResponse {
items: page
.users
.into_iter()
.map(|pubkey| DisabledUser {
pubkey: pubkey.z32(),
})
.collect(),
next_cursor: page.next_cursor.map(|cursor| cursor.z32()),
};

Ok((StatusCode::OK, Json(body)))
}

#[cfg(test)]
mod tests {
use super::super::super::app_state::AppState;
use super::*;
use crate::{persistence::files::FileService, AppContext};
use axum::routing::post;
use axum::routing::{get, post};
use axum::Router;
use pubky_common::crypto::Keypair;

Expand Down Expand Up @@ -102,6 +155,7 @@ mod tests {
let router = Router::new()
.route("/users/{pubkey}/disable", post(disable_user))
.route("/users/{pubkey}/enable", post(enable_user))
.route("/users/disabled", get(list_disabled_users))
.with_state(app_state);

// Disable the tenant
Expand Down Expand Up @@ -130,4 +184,76 @@ mod tests {
.unwrap();
assert!(!user.disabled);
}

#[tokio::test]
#[pubky_test_utils::test]
async fn test_list_disabled_users() {
let context = AppContext::test().await;
let user_a = Keypair::random().public_key();
let user_b = Keypair::random().public_key();
let user_c = Keypair::random().public_key();

let mut user_a_entity = UserRepository::create(&user_a, &mut context.sql_db.pool().into())
.await
.unwrap();
let mut user_b_entity = UserRepository::create(&user_b, &mut context.sql_db.pool().into())
.await
.unwrap();
let _ = UserRepository::create(&user_c, &mut context.sql_db.pool().into())
.await
.unwrap();

user_a_entity.disabled = true;
user_b_entity.disabled = true;
UserRepository::update(&user_a_entity, &mut context.sql_db.pool().into())
.await
.unwrap();
UserRepository::update(&user_b_entity, &mut context.sql_db.pool().into())
.await
.unwrap();

let app_state = AppState::new(
context.sql_db.clone(),
FileService::new_from_context(&context).unwrap(),
"",
);
let router = Router::new()
.route("/users/disabled", get(list_disabled_users))
.with_state(app_state);
let server = axum_test::TestServer::new(router).unwrap();

let response = server.get("/users/disabled?limit=1").await;
assert_eq!(response.status_code(), StatusCode::OK);
let body: ListDisabledUsersResponse = response.json();
assert_eq!(body.items.len(), 1);
assert!(body.next_cursor.is_some());

let cursor = body.next_cursor.expect("limit=1 should produce cursor");
let response = server
.get(format!("/users/disabled?limit=10&cursor={cursor}").as_str())
.await;
assert_eq!(response.status_code(), StatusCode::OK);
let body: ListDisabledUsersResponse = response.json();
assert_eq!(body.items.len(), 1);
assert!(body.next_cursor.is_none());
assert_ne!(body.items[0].pubkey, user_c.z32());
}

#[tokio::test]
#[pubky_test_utils::test]
async fn test_list_disabled_users_invalid_cursor() {
let context = AppContext::test().await;
let app_state = AppState::new(
context.sql_db.clone(),
FileService::new_from_context(&context).unwrap(),
"",
);
let router = Router::new()
.route("/users/disabled", get(list_disabled_users))
.with_state(app_state);
let server = axum_test::TestServer::new(router).unwrap();

let response = server.get("/users/disabled?cursor=not-a-pubky").await;
assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
}
}
126 changes: 125 additions & 1 deletion pubky-homeserver/src/persistence/sql/entities/user.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::constants::{DEFAULT_LIST_LIMIT, DEFAULT_MAX_LIST_LIMIT};
use pubky_common::crypto::PublicKey;
use sea_query::{Expr, Iden, PostgresQueryBuilder, Query, SimpleExpr};
use sea_query::{Expr, Iden, Order, PostgresQueryBuilder, Query, SimpleExpr};
use sea_query_binder::SqlxBinder;
use sqlx::{postgres::PgRow, FromRow, Row};

Expand Down Expand Up @@ -140,6 +141,57 @@ impl UserRepository {
Ok(overview)
}

/// Get disabled users in deterministic order using cursor pagination.
pub async fn list_disabled<'a>(
limit: Option<u16>,
cursor: Option<PublicKey>,
executor: &mut UnifiedExecutor<'a>,
) -> Result<DisabledUsersPage, sqlx::Error> {
let page_limit = limit
.unwrap_or(DEFAULT_LIST_LIMIT)
.min(DEFAULT_MAX_LIST_LIMIT);

let mut statement = Query::select()
.from(USER_TABLE)
.column(UserIden::PublicKey)
.and_where(Expr::col(UserIden::Disabled).eq(true))
.order_by(UserIden::PublicKey, Order::Asc)
// Fetch one extra item so we can determine whether a next page exists.
.limit((page_limit + 1).into())
.to_owned();

if let Some(cursor) = cursor {
statement = statement
.and_where(Expr::col(UserIden::PublicKey).gt(cursor.z32()))
.to_owned();
}

let (query, values) = statement.build_sqlx(PostgresQueryBuilder);
let con = executor.get_con().await?;
let rows: Vec<PgRow> = sqlx::query_with(&query, values).fetch_all(con).await?;

let mut pubkeys = rows
.iter()
.map(|row| {
let raw_pubkey: String = row.try_get(UserIden::PublicKey.to_string().as_str())?;
PublicKey::try_from_z32(raw_pubkey.as_str())
.map_err(|e| sqlx::Error::Decode(Box::new(e)))
})
.collect::<Result<Vec<_>, sqlx::Error>>()?;

let next_cursor = if pubkeys.len() > page_limit as usize {
pubkeys.pop();
pubkeys.last().cloned()
} else {
None
};

Ok(DisabledUsersPage {
users: pubkeys,
next_cursor,
})
}

pub async fn update<'a>(
user: &UserEntity,
executor: &mut UnifiedExecutor<'a>,
Expand Down Expand Up @@ -212,6 +264,12 @@ pub struct UserOverview {
pub total_used_mb: u64,
}

#[derive(Debug, PartialEq, Eq, Clone)]
pub struct DisabledUsersPage {
pub users: Vec<PublicKey>,
pub next_cursor: Option<PublicKey>,
}

impl FromRow<'_, PgRow> for UserEntity {
fn from_row(row: &PgRow) -> Result<Self, sqlx::Error> {
let id: i32 = row.try_get(UserIden::Id.to_string().as_str())?;
Expand Down Expand Up @@ -387,4 +445,70 @@ mod tests {
assert_eq!(overview.disabled_count, 1); // One disabled user
assert_eq!(overview.total_used_mb, 3072); // 1024 + 2048
}

#[tokio::test]
#[pubky_test_utils::test]
async fn test_list_disabled_with_pagination() {
let db = SqlDb::test().await;
let user1_pubkey = Keypair::random().public_key();
let user2_pubkey = Keypair::random().public_key();
let user3_pubkey = Keypair::random().public_key();
let user4_pubkey = Keypair::random().public_key();

let mut user1 = UserRepository::create(&user1_pubkey, &mut db.pool().into())
.await
.unwrap();
let mut user2 = UserRepository::create(&user2_pubkey, &mut db.pool().into())
.await
.unwrap();
let mut user3 = UserRepository::create(&user3_pubkey, &mut db.pool().into())
.await
.unwrap();
let user4 = UserRepository::create(&user4_pubkey, &mut db.pool().into())
.await
.unwrap();

user1.disabled = true;
user2.disabled = true;
user3.disabled = true;
UserRepository::update(&user1, &mut db.pool().into())
.await
.unwrap();
UserRepository::update(&user2, &mut db.pool().into())
.await
.unwrap();
UserRepository::update(&user3, &mut db.pool().into())
.await
.unwrap();

let page1 = UserRepository::list_disabled(Some(2), None, &mut db.pool().into())
.await
.unwrap();
assert_eq!(page1.users.len(), 2);
assert!(page1.next_cursor.is_some());

let page2 =
UserRepository::list_disabled(Some(2), page1.next_cursor, &mut db.pool().into())
.await
.unwrap();
assert_eq!(page2.users.len(), 1);
assert!(page2.next_cursor.is_none());

let mut seen = page1
.users
.iter()
.chain(page2.users.iter())
.cloned()
.collect::<Vec<_>>();
seen.sort_by_key(|pk| pk.z32());

let mut expected = vec![user1.public_key, user2.public_key, user3.public_key];
expected.sort_by_key(|pk| pk.z32());

assert_eq!(seen, expected);
assert!(
!seen.contains(&user4.public_key),
"enabled user must not be listed"
);
}
}