eba4fd1
use export::pagination::Page;
use models::dir::dirs;
use models::repo::repos;
use models::room::rooms;
use models::space::space_members::{self, MemberStatus, MessageNotifications};
use models::space::spaces;
use models::users::user;
use sea_orm::sea_query::Expr;
use sea_orm::{
ActiveModelTrait, ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect, Set,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use crate::errors::AdminError;
use crate::services::service::AdminService;
use crate::share::roles::{AdminActor, AdminRole};
#[derive(Debug, Clone, Deserialize, IntoParams)]
pub struct ListSpacesQuery {
pub q: Option<String>,
pub status: Option<String>,
pub owner_id: Option<Uuid>,
pub limit: Option<u64>,
pub offset: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, IntoParams)]
pub struct ListMembersQuery {
pub status: Option<String>,
pub limit: Option<u64>,
pub offset: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AdminSpaceSummary {
pub id: Uuid,
pub name: String,
pub lower_name: String,
pub description: Option<String>,
pub owner_id: Uuid,
pub owner_username: Option<String>,
pub is_private: bool,
pub is_active: bool,
pub allow_join: bool,
pub allow_invite: bool,
pub member_must_enable_2fa: bool,
pub nums_members: i64,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AdminSpaceDetail {
pub space: AdminSpaceSummary,
pub repos_count: i64,
pub repos_size: i64,
pub rooms_count: i64,
pub dirs_count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateSpaceCmd {
#[serde(default)]
pub name: Option<String>,
#[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
pub description: Option<Option<String>>,
#[serde(default)]
pub is_private: Option<bool>,
#[serde(default)]
pub is_active: Option<bool>,
#[serde(default)]
pub allow_join: Option<bool>,
#[serde(default)]
pub allow_invite: Option<bool>,
#[serde(default)]
pub member_must_enable_2fa: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct AddSpaceMemberCmd {
pub user_id: Uuid,
#[serde(default)]
pub alias_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AddSpaceMemberResp {
pub space_id: Uuid,
pub user_id: Uuid,
pub status: String,
pub added: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct TransferSpaceCmd {
pub new_owner_id: Uuid,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AdminSpaceMemberDto {
pub user_id: Uuid,
pub username: Option<String>,
pub display_name: Option<String>,
pub alias_name: Option<String>,
pub status: String,
pub joined_at: OffsetDateTime,
pub last_active_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct RemoveMemberResp {
pub space_id: Uuid,
pub user_id: Uuid,
pub removed: bool,
}
fn member_status_str(status: MemberStatus) -> String {
match status {
MemberStatus::Active => "active",
MemberStatus::Inactive => "inactive",
MemberStatus::Left => "left",
MemberStatus::Removed => "removed",
}
.to_string()
}
impl AdminService {
async fn owner_usernames(
&self,
owner_ids: &[Uuid],
) -> Result<std::collections::HashMap<Uuid, String>, AdminError> {
if owner_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
let rows = user::Entity::find()
.filter(user::Column::Uid.is_in(owner_ids.to_vec()))
.all(self.db().read())
.await?;
Ok(rows
.into_iter()
.map(|row| (row.uid, row.username))
.collect())
}
pub async fn list_spaces(
&self,
query: ListSpacesQuery,
) -> Result<Page<AdminSpaceSummary>, AdminError> {
let limit = query.limit.unwrap_or(50).clamp(1, 100);
let offset = query.offset.unwrap_or(0);
let mut select = spaces::Entity::find();
if let Some(raw) = query
.q
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let needle = raw.to_ascii_lowercase();
select = select.filter(
Condition::any()
.add(spaces::Column::LowerName.contains(needle))
.add(spaces::Column::Name.contains(raw)),
);
}
if let Some(owner_id) = query.owner_id {
select = select.filter(spaces::Column::OwnerId.eq(owner_id));
}
select = match query.status.as_deref() {
Some("active") => select.filter(spaces::Column::IsActive.eq(true)),
Some("disabled") => select.filter(spaces::Column::IsActive.eq(false)),
_ => select,
};
select = select.order_by_desc(spaces::Column::CreatedAt);
let total = select.clone().count(self.db().read()).await? as i64;
let rows = select
.limit(limit)
.offset(offset)
.all(self.db().read())
.await?;
let owner_ids: Vec<Uuid> = rows.iter().map(|row| row.owner_id).collect();
let owners = self.owner_usernames(&owner_ids).await?;
let items = rows
.iter()
.map(|row| AdminSpaceSummary {
id: row.id,
name: row.name.clone(),
lower_name: row.lower_name.clone(),
description: row.description.clone(),
owner_id: row.owner_id,
owner_username: owners.get(&row.owner_id).cloned(),
is_private: row.is_private,
is_active: row.is_active,
allow_join: row.allow_join,
allow_invite: row.allow_invite,
member_must_enable_2fa: row.member_must_enable_2fa,
nums_members: row.nums_members,
created_at: row.created_at,
updated_at: row.updated_at,
})
.collect();
Ok(Page {
items,
total,
limit,
offset,
})
}
pub async fn get_space(&self, id: Uuid) -> Result<AdminSpaceDetail, AdminError> {
let row = spaces::Entity::find_by_id(id)
.one(self.db().read())
.await?
.ok_or(AdminError::NotFound)?;
let owners = self.owner_usernames(&[row.owner_id]).await?;
let dir_ids: Vec<Uuid> = dirs::Entity::find()
.filter(dirs::Column::SpaceId.eq(id))
.all(self.db().read())
.await?
.into_iter()
.map(|dir| dir.id)
.collect();
let dirs_count = dir_ids.len() as i64;
let (repos_count, repos_size) = if dir_ids.is_empty() {
(0, 0)
} else {
let repo_rows = repos::Entity::find()
.filter(repos::Column::DirId.is_in(dir_ids))
.all(self.db().read())
.await?;
let count = repo_rows.len() as i64;
let size = repo_rows.iter().map(|repo| repo.size).sum();
(count, size)
};
let rooms_count = rooms::Entity::find()
.filter(rooms::Column::SpaceId.eq(id))
.count(self.db().read())
.await? as i64;
Ok(AdminSpaceDetail {
space: AdminSpaceSummary {
id: row.id,
name: row.name.clone(),
lower_name: row.lower_name.clone(),
description: row.description.clone(),
owner_id: row.owner_id,
owner_username: owners.get(&row.owner_id).cloned(),
is_private: row.is_private,
is_active: row.is_active,
allow_join: row.allow_join,
allow_invite: row.allow_invite,
member_must_enable_2fa: row.member_must_enable_2fa,
nums_members: row.nums_members,
created_at: row.created_at,
updated_at: row.updated_at,
},
repos_count,
repos_size,
rooms_count,
dirs_count,
})
}
pub async fn update_space(
&self,
actor: &AdminActor,
id: Uuid,
cmd: UpdateSpaceCmd,
) -> Result<AdminSpaceDetail, AdminError> {
actor.require(AdminRole::Operator)?;
let row = spaces::Entity::find_by_id(id)
.one(self.db().read())
.await?
.ok_or(AdminError::NotFound)?;
let mut am: spaces::ActiveModel = row.into();
let mut changed: Vec<String> = Vec::new();
if let Some(name) = cmd.name {
let name = name.trim().to_string();
if name.is_empty() || name.chars().count() > 64 {
return Err(AdminError::BadRequest(
"name must be 1 to 64 characters".to_string(),
));
}
am.name = Set(name);
changed.push("name".to_string());
}
if let Some(description) = cmd.description {
am.description = Set(description.map(|value| value.trim().to_string()));
changed.push("description".to_string());
}
if let Some(value) = cmd.is_private {
am.is_private = Set(value);
changed.push("is_private".to_string());
}
if let Some(value) = cmd.is_active {
am.is_active = Set(value);
changed.push("is_active".to_string());
}
if let Some(value) = cmd.allow_join {
am.allow_join = Set(value);
changed.push("allow_join".to_string());
}
if let Some(value) = cmd.allow_invite {
am.allow_invite = Set(value);
changed.push("allow_invite".to_string());
}
if let Some(value) = cmd.member_must_enable_2fa {
am.member_must_enable_2fa = Set(value);
changed.push("member_must_enable_2fa".to_string());
}
if changed.is_empty() {
return self.get_space(id).await;
}
am.updated_at = Set(OffsetDateTime::now_utc());
am.update(self.db().write()).await?;
self.get_space(id).await
}
pub async fn transfer_space(
&self,
actor: &AdminActor,
id: Uuid,
cmd: TransferSpaceCmd,
) -> Result<AdminSpaceDetail, AdminError> {
actor.require(AdminRole::SuperAdmin)?;
let row = spaces::Entity::find_by_id(id)
.one(self.db().read())
.await?
.ok_or(AdminError::NotFound)?;
let new_owner = user::Entity::find_by_id(cmd.new_owner_id)
.one(self.db().read())
.await?
.ok_or_else(|| AdminError::BadRequest("new owner does not exist".to_string()))?;
if !new_owner.is_active {
return Err(AdminError::BadRequest(
"new owner account is not active".to_string(),
));
}
if new_owner.uid == row.owner_id {
return Err(AdminError::Conflict(
"new owner is already the space owner".to_string(),
));
}
let mut am: spaces::ActiveModel = row.into();
am.owner_id = Set(new_owner.uid);
am.updated_at = Set(OffsetDateTime::now_utc());
am.update(self.db().write()).await?;
self.get_space(id).await
}
pub async fn delete_space(&self, actor: &AdminActor, id: Uuid) -> Result<(), AdminError> {
actor.require(AdminRole::SuperAdmin)?;
let row = spaces::Entity::find_by_id(id)
.one(self.db().read())
.await?
.ok_or(AdminError::NotFound)?;
if !row.is_active {
return Ok(());
}
let mut am: spaces::ActiveModel = row.into();
am.is_active = Set(false);
am.updated_at = Set(OffsetDateTime::now_utc());
am.update(self.db().write()).await?;
Ok(())
}
pub async fn list_space_members(
&self,
space_id: Uuid,
query: ListMembersQuery,
) -> Result<Page<AdminSpaceMemberDto>, AdminError> {
let limit = query.limit.unwrap_or(50).clamp(1, 100);
let offset = query.offset.unwrap_or(0);
let mut select =
space_members::Entity::find().filter(space_members::Column::SpaceId.eq(space_id));
select = match query.status.as_deref() {
Some("active") => select.filter(space_members::Column::Status.eq(MemberStatus::Active)),
Some("inactive") => {
select.filter(space_members::Column::Status.eq(MemberStatus::Inactive))
}
Some("left") => select.filter(space_members::Column::Status.eq(MemberStatus::Left)),
Some("removed") => {
select.filter(space_members::Column::Status.eq(MemberStatus::Removed))
}
_ => select,
};
select = select.order_by_desc(space_members::Column::JoinedAt);
let total = select.clone().count(self.db().read()).await? as i64;
let rows = select
.limit(limit)
.offset(offset)
.all(self.db().read())
.await?;
let user_ids: Vec<Uuid> = rows.iter().map(|row| row.user_id).collect();
let users: std::collections::HashMap<Uuid, (String, String)> = if user_ids.is_empty() {
std::collections::HashMap::new()
} else {
user::Entity::find()
.filter(user::Column::Uid.is_in(user_ids))
.all(self.db().read())
.await?
.into_iter()
.map(|row| (row.uid, (row.username, row.display_name)))
.collect()
};
let items = rows
.iter()
.map(|row| {
let info = users.get(&row.user_id);
AdminSpaceMemberDto {
user_id: row.user_id,
username: info.map(|value| value.0.clone()),
display_name: info.map(|value| value.1.clone()),
alias_name: row.alias_name.clone(),
status: member_status_str(row.status),
joined_at: row.joined_at,
last_active_at: row.last_active_at,
}
})
.collect();
Ok(Page {
items,
total,
limit,
offset,
})
}
pub async fn remove_space_member(
&self,
actor: &AdminActor,
space_id: Uuid,
user_id: Uuid,
) -> Result<RemoveMemberResp, AdminError> {
actor.require(AdminRole::Operator)?;
let space = spaces::Entity::find_by_id(space_id)
.one(self.db().read())
.await?
.ok_or(AdminError::NotFound)?;
let row = space_members::Entity::find_by_id((space_id, user_id))
.one(self.db().read())
.await?
.ok_or(AdminError::NotFound)?;
if row.status == MemberStatus::Removed {
return Ok(RemoveMemberResp {
space_id,
user_id,
removed: false,
});
}
let was_active = row.status == MemberStatus::Active;
let now = OffsetDateTime::now_utc();
let mut am: space_members::ActiveModel = row.into();
am.status = Set(MemberStatus::Removed);
am.left_at = Set(Some(now));
am.updated_at = Set(Some(now));
am.update(self.db().write()).await?;
if was_active {
spaces::Entity::update_many()
.col_expr(
spaces::Column::NumsMembers,
Expr::cust("GREATEST(nums_members - 1, 0)"),
)
.filter(spaces::Column::Id.eq(space.id))
.exec(self.db().write())
.await?;
}
Ok(RemoveMemberResp {
space_id,
user_id,
removed: true,
})
}
}
impl AdminService {
pub async fn add_space_member(
&self,
actor: &AdminActor,
space_id: Uuid,
cmd: AddSpaceMemberCmd,
) -> Result<AddSpaceMemberResp, AdminError> {
actor.require(AdminRole::Operator)?;
let space = spaces::Entity::find_by_id(space_id)
.one(self.db().read())
.await?
.ok_or(AdminError::NotFound)?;
let target = user::Entity::find_by_id(cmd.user_id)
.one(self.db().read())
.await?
.ok_or_else(|| AdminError::BadRequest("user does not exist".to_string()))?;
if space.member_must_enable_2fa && !target.totp_enabled {
return Err(AdminError::BadRequest(
"space requires members to enable two-factor authentication".to_string(),
));
}
if let Some(alias) = cmd.alias_name.as_deref()
&& alias.chars().count() > self.config().space.max_alias_name_len
{
return Err(AdminError::BadRequest("alias name is too long".to_string()));
}
let existing = space_members::Entity::find_by_id((space_id, cmd.user_id))
.one(self.db().read())
.await?;
let now = OffsetDateTime::now_utc();
match existing {
Some(row) if row.status == MemberStatus::Active => Ok(AddSpaceMemberResp {
space_id,
user_id: cmd.user_id,
status: member_status_str(MemberStatus::Active),
added: false,
}),
Some(row) => {
let mut am: space_members::ActiveModel = row.into();
am.status = Set(MemberStatus::Active);
am.left_at = Set(None);
am.alias_name = Set(cmd.alias_name.clone());
am.updated_at = Set(Some(now));
am.update(self.db().write()).await?;
spaces::Entity::update_many()
.col_expr(spaces::Column::NumsMembers, Expr::cust("nums_members + 1"))
.filter(spaces::Column::Id.eq(space_id))
.exec(self.db().write())
.await?;
Ok(AddSpaceMemberResp {
space_id,
user_id: cmd.user_id,
status: member_status_str(MemberStatus::Active),
added: true,
})
}
None => {
space_members::ActiveModel {
space_id: Set(space_id),
user_id: Set(cmd.user_id),
alias_name: Set(cmd.alias_name.clone()),
alias_avatar_url: Set(None),
status: Set(MemberStatus::Active),
last_active_at: Set(None),
left_at: Set(None),
invited_at: Set(None),
invited_by: Set(None),
joined_at: Set(now),
created_at: Set(now),
updated_at: Set(None),
message_notifications: Set(MessageNotifications::default()),
muted: Set(false),
mute_until: Set(None),
mobile_push: Set(false),
suppress_everyone: Set(false),
suppress_roles: Set(false),
hide_muted_channels: Set(false),
}
.insert(self.db().write())
.await?;
spaces::Entity::update_many()
.col_expr(spaces::Column::NumsMembers, Expr::cust("nums_members + 1"))
.filter(spaces::Column::Id.eq(space_id))
.exec(self.db().write())
.await?;
Ok(AddSpaceMemberResp {
space_id,
user_id: cmd.user_id,
status: member_status_str(MemberStatus::Active),
added: true,
})
}
}
}
}