2984e96
ZhenYi
refactor: simplify pagination logic across multiple modules for improved consistency and clarity
2984e96
use std::collections::HashMap;
use models::space::spaces::{self, AllowJoinLevel};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use crate::authority;
use crate::errors::SpaceError;
use crate::services::service::SpaceService;
use crate::share::{authz, lookup};
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 == '-')
}
pub use export::users::UserDto;
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;
fn space_dto(space: spaces::Model, owners: &HashMap<i64, UserDto>) -> SpaceDto {
let owner = owners.get(&space.owner_id).cloned();
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,
}
}
#[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> {
Ok(export::users::load_user_briefs(&self.state, uids).await?)
}
#[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 client = authority::client(&self.state)?;
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),
Some(cmd.allow_invite),
Some(i32::from(allow_join_level.as_i16())),
)
.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())
})?;
authority::upsert_extras(
&self.state,
space_id,
None,
Some(cmd.welcome_object),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.await?;
let space = authority::space_by_id(&self.state, caller, space_id).await?;
Ok(space_dto(space, &self.load_user_briefs(&[caller]).await?))
}
pub async fn get_space(
&self,
caller: Option<i64>,
space_name: &str,
) -> Result<SpaceDto, SpaceError> {
let actor = caller.unwrap_or(0);
let space = lookup::lookup_space_by_name(self, actor, 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 authz::effective_permissions(&self.state, &space, uid).await {
Ok(_) => {}
Err(SpaceError::Forbidden) => return Err(SpaceError::NotFound),
Err(e) => return Err(e),
}
}
Ok(space_dto(
space.clone(),
&self.load_user_briefs(&[space.owner_id]).await?,
))
}
#[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::lookup_space_by_name(self, caller, 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(),
));
}
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);
authority::client(&self.state)?
.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,
cmd.allow_invite,
Some(i32::from(allow_join_level.as_i16())),
)
.await?;
if cmd.welcome_object.is_some() {
authority::upsert_extras(
&self.state,
space.id,
None,
cmd.welcome_object,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.await?;
}
let space = authority::space_by_id(&self.state, caller, space.id).await?;
Ok(space_dto(
space.clone(),
&self.load_user_briefs(&[space.owner_id]).await?,
))
}
#[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 lower = space_name.trim().to_lowercase();
if lower.is_empty() {
return Err(SpaceError::NotFound);
}
// Use the authoritative lookup (not the active-only one) so a retried
// DELETE stays idempotent after the organization has been archived.
let space = authority::space_by_slug(&self.state, caller, &lower).await?;
if space.owner_id != caller {
return Err(SpaceError::Forbidden);
}
deletion::request_space_deletion(&self.state, caller, space.id, "space deleted").await?;
Ok(())
}
pub async fn restore_space(&self, caller: i64, space_name: &str) -> Result<(), SpaceError> {
let lower = space_name.trim().to_lowercase();
if lower.is_empty() {
return Err(SpaceError::NotFound);
}
let space = authority::space_by_slug(&self.state, caller, &lower).await?;
if space.owner_id != caller {
return Err(SpaceError::Forbidden);
}
deletion::cancel_space_deletion(&self.state, caller, space.id).await?;
Ok(())
}
pub async fn list_spaces(&self, caller: i64, q: PageQuery) -> Result<SpacePage, SpaceError> {
let (limit, offset) = export::pagination::page_bounds(
q.limit,
q.offset,
self.config().space.default_pagination,
self.config().space.max_pagination,
);
let all: Vec<spaces::Model> = authority::list_user_spaces(&self.state, caller, caller)
.await?
.into_iter()
.filter(|space| space.is_active && space.owner_id == caller)
.collect();
let total = all.len() as i64;
let owner_ids: Vec<i64> = all.iter().map(|s| s.owner_id).collect();
let owner_map: HashMap<i64, UserDto> = self.load_user_briefs(&owner_ids).await?;
let items = all
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.map(|space| space_dto(space, &owner_map))
.collect();
Ok(SpacePage {
items,
total,
limit,
offset,
})
}
}