use std::collections::HashMap; use models::space::spaces::{self, AllowJoinLevel}; use models::users::user; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::errors::SpaceError; use crate::services::service::SpaceService; use crate::share::authz::effective_permissions; use crate::share::lookup::lookup_space_by_name; fn default_true() -> bool { true } /// Slugs reserved by frontend routes; a space may not claim them. pub const RESERVED_SPACE_NAMES: [&str; 1] = ["explore"]; fn is_reserved_lower_name(name: &str) -> bool { RESERVED_SPACE_NAMES.contains(&name) } fn is_valid_lower_name(name: &str) -> bool { !name.is_empty() && name .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UserDto { pub user_id: i64, pub username: String, pub display_name: String, pub avatar_url: String, } impl From for UserDto { fn from(u: user::Model) -> Self { Self { user_id: u.uid, username: u.username, display_name: u.display_name, avatar_url: resolve_user_avatar_url(u.uid, &u.avatar_url), } } } pub use export::{resolve_space_image_url, resolve_user_avatar_url}; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct SpaceDto { pub id: Uuid, pub lower_name: String, pub name: String, pub description: Option, pub owner: Option, pub avatar_url: Option, pub is_private: bool, pub is_active: bool, pub allow_join: bool, pub allow_invite: bool, pub members_count: i64, pub welcome_message: Option, pub allow_join_level: AllowJoinLevel, pub member_must_enable_2fa: bool, pub welcome_object: Option, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateSpaceCmd { pub name: String, #[serde(default)] pub description: Option, #[serde(default)] pub avatar_url: Option, #[serde(default)] pub is_private: bool, #[serde(default = "default_true")] pub allow_join: bool, #[serde(default = "default_true")] pub allow_invite: bool, #[serde(default)] pub welcome_message: Option, #[serde(default)] pub allow_join_level: Option, #[serde(default)] pub member_must_enable_2fa: bool, #[serde(default)] pub welcome_object: Option, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateSpaceCmd { #[serde(default)] pub name: Option, #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")] pub description: Option>, #[serde(default)] pub is_private: Option, #[serde(default)] pub allow_join: Option, #[serde(default)] pub allow_invite: Option, #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")] pub welcome_message: Option>, #[serde(default)] pub allow_join_level: Option, #[serde(default)] pub member_must_enable_2fa: Option, #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")] pub welcome_object: Option>, } pub type SpacePage = export::pagination::Page; pub use export::pagination::PageQuery; #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct SpaceNamePath { pub space_name: String, } impl SpaceService { pub async fn load_user_briefs( &self, uids: &[i64], ) -> Result, SpaceError> { if uids.is_empty() { return Ok(HashMap::new()); } let unique_uids: Vec = { let mut set: Vec = uids.to_vec(); set.sort(); set.dedup(); set }; let users = user::Entity::find() .filter(user::Column::Uid.is_in(unique_uids)) .all(self.db().read()) .await?; let map = users .into_iter() .map(|u| (u.uid, UserDto::from(u))) .collect(); Ok(map) } #[tracing::instrument(name = "space.create_space", skip_all, fields(caller = %caller))] pub async fn create_space( &self, caller: i64, cmd: CreateSpaceCmd, ) -> Result { if !self .state .redis .quota_allow( &format!("quota:space_create:{caller}"), self.config().quota.space_create_per_day, 86_400, ) .await { return Err(SpaceError::RateLimited( "space creation quota exceeded, retry tomorrow".to_string(), )); } let name = cmd.name.trim(); if name.is_empty() || name.chars().count() > self.config().space.max_space_name_len { return Err(SpaceError::BadRequest( "space name is empty or too long".to_string(), )); } if let Some(desc) = &cmd.description && desc.chars().count() > self.config().space.max_space_desc_len { return Err(SpaceError::BadRequest( "space description is too long".to_string(), )); } let lower = name.to_lowercase(); if !is_valid_lower_name(&lower) { return Err(SpaceError::BadRequest( "invalid space name: must contain only lowercase letters, digits, and hyphens" .to_string(), )); } if is_reserved_lower_name(&lower) { return Err(SpaceError::Conflict(format!( "space name {lower} is reserved" ))); } let existing = spaces::Entity::find() .filter(spaces::Column::LowerName.eq(&lower)) .one(self.db().read()) .await?; if existing.is_some() { return Err(SpaceError::Conflict(format!( "space name {lower} is already taken" ))); } let client = self .state .kenauth .as_ref() .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?; let allow_join_level = cmd.allow_join_level.unwrap_or(AllowJoinLevel::None); let policy = if cmd.is_private { 4 } else if cmd.allow_join { 1 } else { 3 }; let require_email_verified = !matches!(allow_join_level, AllowJoinLevel::None); let created = client .create_organization( caller, &lower, name, cmd.description.as_deref().unwrap_or_default(), cmd.avatar_url.as_deref().unwrap_or_default(), Some(policy), Some(cmd.allow_join), Some(require_email_verified), Some(false), cmd.welcome_message.clone(), Some(cmd.member_must_enable_2fa), ) .await?; let organization = created .organization .ok_or_else(|| SpaceError::Internal("kenoauth returned no organization".to_string()))?; let space_id = Uuid::parse_str(&organization.id).map_err(|_| { SpaceError::Internal("kenoauth returned a non-uuid organization".to_string()) })?; let mut inserted_space = crate::sync::upsert_space(self.db().write(), &organization).await?; let mut local: spaces::ActiveModel = inserted_space.clone().into(); local.allow_invite = Set(cmd.allow_invite); local.allow_join_level = Set(allow_join_level); local.welcome_object = Set(cmd.welcome_object); inserted_space = local.update(self.db().write()).await?; if let Some(member) = created.member.as_ref() { crate::sync::upsert_member(self.db().write(), space_id, member).await?; } let owner_map: HashMap = self.load_user_briefs(&[caller]).await?; let owner = owner_map.get(&caller).cloned(); Ok(SpaceDto { id: inserted_space.id, lower_name: inserted_space.lower_name.clone(), name: inserted_space.name, description: inserted_space.description, owner, avatar_url: resolve_space_image_url( &inserted_space.lower_name, "avatar", inserted_space.avatar_url, ), is_private: inserted_space.is_private, is_active: inserted_space.is_active, allow_join: inserted_space.allow_join, allow_invite: inserted_space.allow_invite, members_count: inserted_space.nums_members, welcome_message: inserted_space.welcome_message, allow_join_level: inserted_space.allow_join_level, member_must_enable_2fa: inserted_space.member_must_enable_2fa, welcome_object: inserted_space.welcome_object, created_at: inserted_space.created_at, updated_at: inserted_space.updated_at, }) } pub async fn get_space( &self, caller: Option, space_name: &str, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; if !space.is_active { return Err(SpaceError::NotFound); } if space.is_private { let Some(uid) = caller else { return Err(SpaceError::NotFound); }; match effective_permissions(&self.state, &space, uid).await { Ok(_) => {} Err(SpaceError::Forbidden) => return Err(SpaceError::NotFound), Err(e) => return Err(e), } } let owner_map: HashMap = self.load_user_briefs(&[space.owner_id]).await?; let owner = owner_map.get(&space.owner_id).cloned(); Ok(SpaceDto { id: space.id, lower_name: space.lower_name.clone(), name: space.name, description: space.description, owner, avatar_url: resolve_space_image_url(&space.lower_name, "avatar", space.avatar_url), is_private: space.is_private, is_active: space.is_active, allow_join: space.allow_join, allow_invite: space.allow_invite, members_count: space.nums_members, welcome_message: space.welcome_message, allow_join_level: space.allow_join_level, member_must_enable_2fa: space.member_must_enable_2fa, welcome_object: space.welcome_object, created_at: space.created_at, updated_at: space.updated_at, }) } #[tracing::instrument(name = "space.update_space", skip_all, fields(caller = %caller, space = %space_name))] pub async fn update_space( &self, caller: i64, space_name: &str, cmd: UpdateSpaceCmd, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; if space.owner_id != caller { return Err(SpaceError::Forbidden); } let mut name = None; if let Some(new_name) = cmd.name { let trimmed = new_name.trim(); if trimmed.is_empty() || trimmed.chars().count() > self.config().space.max_space_name_len { return Err(SpaceError::BadRequest( "space name is empty or too long".to_string(), )); } let lower = trimmed.to_lowercase(); if !is_valid_lower_name(&lower) { return Err(SpaceError::BadRequest( "invalid space name: must contain only lowercase letters, digits, and hyphens" .to_string(), )); } if is_reserved_lower_name(&lower) { return Err(SpaceError::Conflict(format!( "space name {lower} is reserved" ))); } let existing = spaces::Entity::find() .filter(spaces::Column::LowerName.eq(&lower)) .filter(spaces::Column::Id.ne(space.id)) .one(self.db().read()) .await?; if existing.is_some() { return Err(SpaceError::Conflict(format!( "space name {lower} is already taken" ))); } name = Some(trimmed.to_string()); } if let Some(desc) = cmd.description.as_ref().and_then(|value| value.as_ref()) && desc.chars().count() > self.config().space.max_space_desc_len { return Err(SpaceError::BadRequest( "space description is too long".to_string(), )); } if let Some(welcome) = cmd.welcome_message.as_ref().and_then(|value| value.as_ref()) && welcome.chars().count() > self.config().space.max_space_desc_len { return Err(SpaceError::BadRequest( "welcome message is too long".to_string(), )); } let next_private = cmd.is_private.unwrap_or(space.is_private); let next_allow_join = cmd.allow_join.unwrap_or(space.allow_join); let policy = if next_private { 4 } else if next_allow_join { 1 } else { 3 }; let allow_join_level = cmd.allow_join_level.unwrap_or(space.allow_join_level); let require_email_verified = !matches!(allow_join_level, AllowJoinLevel::None); let client = self .state .kenauth .as_ref() .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?; let updated = client .update_organization( caller, &space.id.to_string(), name, cmd.description.clone().map(|value| value.unwrap_or_default()), None, 0, Some(policy), Some(next_allow_join), Some(require_email_verified), Some(false), cmd.welcome_message.clone().map(|value| value.unwrap_or_default()), cmd.member_must_enable_2fa, ) .await?; let mut projected = crate::sync::upsert_space(self.db().write(), &updated).await?; let mut local: spaces::ActiveModel = projected.clone().into(); if let Some(allow_invite) = cmd.allow_invite { local.allow_invite = Set(allow_invite); } if let Some(level) = cmd.allow_join_level { local.allow_join_level = Set(level); } if let Some(welcome_object) = cmd.welcome_object { local.welcome_object = Set(welcome_object); } local.updated_at = Set(OffsetDateTime::now_utc()); projected = local.update(self.db().write()).await?; let owner_map: HashMap = self.load_user_briefs(&[projected.owner_id]).await?; let owner = owner_map.get(&projected.owner_id).cloned(); Ok(SpaceDto { id: projected.id, lower_name: projected.lower_name.clone(), name: projected.name, description: projected.description, owner, avatar_url: resolve_space_image_url( &projected.lower_name, "avatar", projected.avatar_url, ), is_private: projected.is_private, is_active: projected.is_active, allow_join: projected.allow_join, allow_invite: projected.allow_invite, members_count: projected.nums_members, welcome_message: projected.welcome_message, allow_join_level: projected.allow_join_level, member_must_enable_2fa: projected.member_must_enable_2fa, welcome_object: projected.welcome_object, created_at: projected.created_at, updated_at: projected.updated_at, }) } #[tracing::instrument(name = "space.delete_space", skip_all, fields(caller = %caller, space = %space_name))] pub async fn delete_space(&self, caller: i64, space_name: &str) -> Result<(), SpaceError> { let space = lookup_space_by_name(self, space_name).await?; if space.owner_id != caller { return Err(SpaceError::Forbidden); } let client = self .state .kenauth .as_ref() .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?; client .delete_organization(caller, &space.id.to_string(), true, "space deleted") .await?; let now = OffsetDateTime::now_utc(); let mut am: spaces::ActiveModel = space.into(); am.is_active = Set(false); am.updated_at = Set(now); am.update(self.db().write()).await?; Ok(()) } pub async fn list_spaces(&self, caller: i64, q: PageQuery) -> Result { 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 = spaces::Entity::find() .filter(spaces::Column::IsActive.eq(true)) .filter(spaces::Column::OwnerId.eq(caller)) .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 = rows.iter().map(|s| s.owner_id).collect(); let owner_map: HashMap = self.load_user_briefs(&owner_ids).await?; let items = rows .into_iter() .map(|s| { let avatar_url = resolve_space_image_url(&s.lower_name, "avatar", s.avatar_url); let owner = owner_map.get(&s.owner_id).cloned(); SpaceDto { id: s.id, lower_name: s.lower_name, name: s.name, description: s.description, owner, avatar_url, is_private: s.is_private, is_active: s.is_active, allow_join: s.allow_join, allow_invite: s.allow_invite, members_count: s.nums_members, welcome_message: s.welcome_message, allow_join_level: s.allow_join_level, member_must_enable_2fa: s.member_must_enable_2fa, welcome_object: s.welcome_object, created_at: s.created_at, updated_at: s.updated_at, } }) .collect(); Ok(SpacePage { items, total, limit, offset, }) } }