b453d5d
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<user::Model> 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<String>,
pub owner: Option<UserDto>,
pub avatar_url: Option<String>,
pub is_private: bool,
pub is_active: bool,
pub allow_join: bool,
pub allow_invite: bool,
pub members_count: i64,
pub welcome_message: Option<String>,
pub allow_join_level: AllowJoinLevel,
pub member_must_enable_2fa: bool,
pub welcome_object: Option<Uuid>,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateSpaceCmd {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub avatar_url: Option<String>,
#[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<String>,
#[serde(default)]
pub allow_join_level: Option<AllowJoinLevel>,
#[serde(default)]
pub member_must_enable_2fa: bool,
#[serde(default)]
pub welcome_object: Option<Uuid>,
}
#[derive(Debug, Clone, 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 allow_join: Option<bool>,
#[serde(default)]
pub allow_invite: Option<bool>,
#[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
pub welcome_message: Option<Option<String>>,
#[serde(default)]
pub allow_join_level: Option<AllowJoinLevel>,
#[serde(default)]
pub member_must_enable_2fa: Option<bool>,
#[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
pub welcome_object: Option<Option<Uuid>>,
}
pub type SpacePage = export::pagination::Page<SpaceDto>;
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<HashMap<i64, UserDto>, SpaceError> {
if uids.is_empty() {
return Ok(HashMap::new());
}
let unique_uids: Vec<i64> = {
let mut set: Vec<i64> = 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<SpaceDto, SpaceError> {
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<i64, UserDto> = 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<i64>,
space_name: &str,
) -> Result<SpaceDto, SpaceError> {
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<i64, UserDto> = 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<SpaceDto, SpaceError> {
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<i64, UserDto> = 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<SpacePage, SpaceError> {
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<i64> = rows.iter().map(|s| s.owner_id).collect();
let owner_map: HashMap<i64, UserDto> = 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,
})
}
}