use std::collections::HashMap; use models::space::space_bans; use models::space::space_member_groups; use models::space::space_members::{self, MemberStatus}; use models::space::spaces; use models::users::user; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::SpaceError; use crate::services::service::SpaceService; use crate::services::spaces::{PageQuery, UserDto}; use crate::share::authz::{MANAGE_BANS, require_perm}; use crate::share::lookup::lookup_space_by_name; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct BanDto { pub space_id: Uuid, pub user: Option, pub reason: Option, pub banned_by: Option, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, pub expires_at: Option, pub allow_speak: bool, pub allow_push_repo: bool, pub allow_comment_any: bool, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct BanUserCmd { pub user_id: i64, #[serde(default)] pub reason: Option, #[serde( default, deserialize_with = "export::serde_datetime::deserialize_opt_datetime" )] pub expires_at: Option, #[serde(default)] pub allow_speak: Option, #[serde(default)] pub allow_push_repo: Option, #[serde(default)] pub allow_comment_any: Option, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateBanCmd { #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")] pub reason: Option>, #[serde( default, deserialize_with = "export::serde_datetime::deserialize_opt_opt_datetime" )] pub expires_at: Option>, #[serde(default)] pub allow_speak: Option, #[serde(default)] pub allow_push_repo: Option, #[serde(default)] pub allow_comment_any: Option, } fn proto_timestamp(value: OffsetDateTime) -> prost_types::Timestamp { prost_types::Timestamp { seconds: value.unix_timestamp(), nanos: value.nanosecond() as i32, } } pub type BanPage = export::pagination::Page; impl SpaceService { #[tracing::instrument(name = "space.ban_user", skip_all, fields(caller = %caller, space = %space_name))] pub async fn ban_user( &self, caller: i64, space_name: &str, cmd: BanUserCmd, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_BANS).await?; user::Entity::find_by_id(cmd.user_id) .one(self.db().read()) .await? .ok_or(SpaceError::NotFound)?; let existing = space_bans::Entity::find_by_id((space.id, cmd.user_id)) .one(self.db().read()) .await?; if existing.is_some() { return Err(SpaceError::Conflict( "user is already banned from this space".to_string(), )); } let client = self .state .kenauth .as_ref() .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?; client .ban_member( caller, &space.id.to_string(), cmd.user_id, cmd.reason.as_deref().unwrap_or_default(), ) .await?; if cmd.expires_at.is_some() || cmd.allow_speak.unwrap_or(false) || cmd.allow_push_repo.unwrap_or(false) || cmd.allow_comment_any.unwrap_or(false) { client .update_ban( caller, &space.id.to_string(), cmd.user_id, None, cmd.expires_at.map(|value| Some(proto_timestamp(value))), cmd.allow_speak, cmd.allow_push_repo, cmd.allow_comment_any, ) .await?; } let member = space_members::Entity::find_by_id((space.id, cmd.user_id)) .one(self.db().read()) .await?; let now = OffsetDateTime::now_utc(); let ban_row = space_bans::ActiveModel { space_id: Set(space.id), user_id: Set(cmd.user_id), reason: Set(cmd.reason), banned_by: Set(caller), created_at: Set(now), updated_at: Set(now), expires_at: Set(cmd.expires_at), allow_speak: Set(cmd.allow_speak.unwrap_or(false)), allow_push_repo: Set(cmd.allow_push_repo.unwrap_or(false)), allow_comment_any: Set(cmd.allow_comment_any.unwrap_or(false)), }; let inserted = self .db() .primary() .transaction::<_, space_bans::Model, SpaceError>(|txn| { Box::pin(async move { let ban = ban_row.insert(txn).await?; if let Some(m) = member { let was_active = m.status == MemberStatus::Active; let mut am: space_members::ActiveModel = m.into(); am.status = Set(MemberStatus::Removed); am.left_at = Set(Some(now)); am.updated_at = Set(Some(now)); am.update(txn).await?; if was_active { spaces::Entity::update_many() .filter(spaces::Column::Id.eq(space.id)) .col_expr( spaces::Column::NumsMembers, sea_orm::sea_query::Expr::cust("GREATEST(nums_members - 1, 0)"), ) .exec(txn) .await?; } } space_member_groups::Entity::delete_many() .filter(space_member_groups::Column::SpaceId.eq(space.id)) .filter(space_member_groups::Column::UserId.eq(cmd.user_id)) .exec(txn) .await?; Ok(ban) }) }) .await?; let user_map: HashMap = self.load_user_briefs(&[inserted.user_id, caller]).await?; let user_brief = user_map.get(&inserted.user_id).cloned(); let actor_brief = user_map.get(&caller).cloned(); Ok(BanDto { space_id: inserted.space_id, user: user_brief, reason: inserted.reason, banned_by: actor_brief, created_at: inserted.created_at, updated_at: inserted.updated_at, expires_at: inserted.expires_at, allow_speak: inserted.allow_speak, allow_push_repo: inserted.allow_push_repo, allow_comment_any: inserted.allow_comment_any, }) } pub async fn get_ban( &self, caller: i64, space_name: &str, uid: i64, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_BANS).await?; let ban = space_bans::Entity::find_by_id((space.id, uid)) .one(self.db().read()) .await? .ok_or(SpaceError::NotFound)?; let user_map: HashMap = self.load_user_briefs(&[ban.user_id, ban.banned_by]).await?; let user_brief = user_map.get(&ban.user_id).cloned(); let actor_brief = user_map.get(&ban.banned_by).cloned(); Ok(BanDto { space_id: ban.space_id, user: user_brief, reason: ban.reason, banned_by: actor_brief, created_at: ban.created_at, updated_at: ban.updated_at, expires_at: ban.expires_at, allow_speak: ban.allow_speak, allow_push_repo: ban.allow_push_repo, allow_comment_any: ban.allow_comment_any, }) } #[tracing::instrument(name = "space.update_ban", skip_all, fields(caller = %caller, space = %space_name, uid = %uid))] pub async fn update_ban( &self, caller: i64, space_name: &str, uid: i64, cmd: UpdateBanCmd, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_BANS).await?; let ban = space_bans::Entity::find_by_id((space.id, uid)) .one(self.db().read()) .await? .ok_or(SpaceError::NotFound)?; let client = self .state .kenauth .as_ref() .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?; client .update_ban( caller, &space.id.to_string(), uid, cmd.reason.clone(), cmd.expires_at.map(|value| value.map(proto_timestamp)), cmd.allow_speak, cmd.allow_push_repo, cmd.allow_comment_any, ) .await?; let mut am: space_bans::ActiveModel = ban.into(); if let Some(reason) = cmd.reason { am.reason = Set(reason); } if let Some(expires_at) = cmd.expires_at { am.expires_at = Set(expires_at); } if let Some(v) = cmd.allow_speak { am.allow_speak = Set(v); } if let Some(v) = cmd.allow_push_repo { am.allow_push_repo = Set(v); } if let Some(v) = cmd.allow_comment_any { am.allow_comment_any = Set(v); } let now = OffsetDateTime::now_utc(); am.updated_at = Set(now); let updated = am.update(self.db().write()).await?; let user_map: HashMap = self .load_user_briefs(&[updated.user_id, updated.banned_by]) .await?; let user_brief = user_map.get(&updated.user_id).cloned(); let actor_brief = user_map.get(&updated.banned_by).cloned(); Ok(BanDto { space_id: updated.space_id, user: user_brief, reason: updated.reason, banned_by: actor_brief, created_at: updated.created_at, updated_at: updated.updated_at, expires_at: updated.expires_at, allow_speak: updated.allow_speak, allow_push_repo: updated.allow_push_repo, allow_comment_any: updated.allow_comment_any, }) } #[tracing::instrument(name = "space.unban_user", skip_all, fields(caller = %caller, space = %space_name, uid = %uid))] pub async fn unban_user( &self, caller: i64, space_name: &str, uid: i64, ) -> Result<(), SpaceError> { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_BANS).await?; let client = self .state .kenauth .as_ref() .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?; client .unban_member(caller, &space.id.to_string(), uid) .await?; let res = space_bans::Entity::delete_by_id((space.id, uid)) .exec(self.db().write()) .await?; if res.rows_affected == 0 { return Err(SpaceError::NotFound); } Ok(()) } pub async fn list_bans( &self, caller: i64, space_name: &str, q: PageQuery, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_BANS).await?; let max_pagination = self.config().space.max_pagination; let default_pagination = self.config().space.default_pagination; let limit = q .limit .unwrap_or(default_pagination) .clamp(1, max_pagination); let offset = q .offset .unwrap_or(0) .clamp(0, export::pagination::MAX_OFFSET); let select = space_bans::Entity::find() .filter(space_bans::Column::SpaceId.eq(space.id)) .order_by_desc(space_bans::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 mut uids: Vec = Vec::new(); for b in &rows { uids.push(b.user_id); uids.push(b.banned_by); } let user_map: HashMap = self.load_user_briefs(&uids).await?; let items = rows .into_iter() .map(|b| { let user_brief = user_map.get(&b.user_id).cloned(); let actor_brief = user_map.get(&b.banned_by).cloned(); BanDto { space_id: b.space_id, user: user_brief, reason: b.reason, banned_by: actor_brief, created_at: b.created_at, updated_at: b.updated_at, expires_at: b.expires_at, allow_speak: b.allow_speak, allow_push_repo: b.allow_push_repo, allow_comment_any: b.allow_comment_any, } }) .collect(); Ok(BanPage { items, total, limit, offset, }) } }