Skip to content
Clone
ef0b83a
445 lines 14.9 KB Rust
use std::collections::HashMap;
use export::AppState;
use models::space::space_extras::{self, Entity as SpaceExtras};
use models::space::space_member_extras::{self, Entity as MemberExtras};
use models::space::space_members::{self, Entity as Members};
use models::space::spaces::{self, Entity as Spaces};
use models::space::{AllowJoinLevel, SpaceJoinPolicy, SpaceStatus};
use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, QuerySelect, Set};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::errors::SpaceError;
use crate::services::dto::{MemberDto, SpaceDto, UserDto, resolve_space_image_url};
use crate::services::service::SpaceService;
pub const RPC_KEY: &str = "kenoauth is not configured";
pub fn client(state: &AppState) -> Result<&dyn export::AuthApi, SpaceError> {
state
.kenauth
.as_deref()
.ok_or_else(|| SpaceError::Internal(RPC_KEY.to_string()))
}
pub fn to_time(value: chrono::DateTime<chrono::Utc>) -> OffsetDateTime {
OffsetDateTime::from_unix_timestamp(value.timestamp())
.map(|row| {
row.replace_nanosecond(value.timestamp_subsec_nanos())
.unwrap_or(row)
})
.unwrap_or_else(|_| OffsetDateTime::now_utc())
}
pub fn to_time_opt(value: Option<chrono::DateTime<chrono::Utc>>) -> Option<OffsetDateTime> {
value.map(to_time)
}
pub fn space_dto(
record: &spaces::Model,
extras: Option<&space_extras::Model>,
nums_members: i64,
owners: &HashMap<i64, UserDto>,
) -> SpaceDto {
SpaceDto {
id: record.id,
lower_name: record.slug.clone(),
name: record.name.clone(),
description: record.description.clone(),
owner: owners.get(&record.owner_user_id).cloned(),
avatar_url: resolve_space_image_url(
&record.slug,
"avatar",
extras
.and_then(|row| row.avatar_url.clone())
.or_else(|| record.avatar_url.clone()),
),
is_private: record.join_policy == SpaceJoinPolicy::Private,
is_active: record.status == SpaceStatus::Active && record.archived_at.is_none(),
allow_join: record.allow_join,
allow_invite: record.allow_invite,
members_count: nums_members,
welcome_message: record.welcome_message.clone(),
allow_join_level: AllowJoinLevel::from_i16(record.allow_join_level),
member_must_enable_2fa: record.member_must_enable_2fa,
welcome_object: extras.and_then(|row| row.welcome_object),
created_at: to_time(record.created_at),
updated_at: to_time(record.updated_at),
}
}
pub fn space_is_active(space: &spaces::Model) -> bool {
space.status == SpaceStatus::Active && space.archived_at.is_none()
}
pub fn member_dto(
record: &space_members::Model,
extras: Option<&space_member_extras::Model>,
users: &HashMap<i64, UserDto>,
) -> MemberDto {
MemberDto {
space_id: record.organization_id,
user: users.get(&record.user_id).cloned(),
alias_name: record.alias_name.clone(),
alias_avatar_url: record.alias_avatar_url.clone(),
status: record.status(),
last_active_at: to_time_opt(record.last_active_at),
left_at: to_time_opt(record.left_at),
invited_at: extras.and_then(|row| row.invited_at),
invited_by: record.invited_by.and_then(|uid| users.get(&uid).cloned()),
joined_at: to_time(record.joined_at),
created_at: extras
.map(|row| row.created_at)
.unwrap_or_else(|| to_time(record.joined_at)),
updated_at: extras.map(|row| row.updated_at),
message_notifications: record.message_notifications,
muted: record.muted,
mute_until: to_time_opt(record.mute_until),
mobile_push: record.mobile_push,
suppress_everyone: record.suppress_everyone,
suppress_roles: record.suppress_roles,
hide_muted_channels: record.hide_muted_channels,
}
}
pub async fn extras(
state: &AppState,
space_id: Uuid,
) -> Result<Option<space_extras::Model>, SpaceError> {
Ok(SpaceExtras::find_by_id(space_id)
.one(state.database.read())
.await?)
}
pub async fn member_extras(
state: &AppState,
space_id: Uuid,
user_id: i64,
) -> Result<Option<space_member_extras::Model>, SpaceError> {
Ok(MemberExtras::find_by_id((space_id, user_id))
.one(state.database.read())
.await?)
}
pub async fn space_avatar(
state: &AppState,
space: &spaces::Model,
) -> Result<Option<String>, SpaceError> {
Ok(extras(state, space.id)
.await?
.and_then(|row| row.avatar_url)
.or_else(|| space.avatar_url.clone()))
}
pub async fn member_count(state: &AppState, space_id: Uuid) -> Result<i64, SpaceError> {
use sea_orm::PaginatorTrait;
let count = Members::find()
.filter(space_members::Column::OrganizationId.eq(space_id))
.filter(space_members::Column::LeftAt.is_null())
.filter(space_members::Column::BannedAt.is_null())
.count(state.database.read())
.await?;
Ok(count as i64)
}
pub async fn space_by_id(
state: &AppState,
_actor: i64,
space_id: Uuid,
) -> Result<spaces::Model, SpaceError> {
Spaces::find_by_id(space_id)
.filter(spaces::Column::DeletedAt.is_null())
.one(state.database.read())
.await?
.ok_or(SpaceError::NotFound)
}
pub async fn space_by_slug(
state: &AppState,
_actor: i64,
slug: &str,
) -> Result<spaces::Model, SpaceError> {
Spaces::find()
.filter(spaces::Column::Slug.eq(slug.trim().to_lowercase()))
.filter(spaces::Column::DeletedAt.is_null())
.one(state.database.read())
.await?
.ok_or(SpaceError::NotFound)
}
pub async fn member(
state: &AppState,
_actor: i64,
space_id: Uuid,
user_id: i64,
) -> Result<Option<space_members::Model>, SpaceError> {
Ok(Members::find()
.filter(space_members::Column::OrganizationId.eq(space_id))
.filter(space_members::Column::UserId.eq(user_id))
.one(state.database.read())
.await?)
}
pub async fn require_member(
state: &AppState,
space_id: Uuid,
user_id: i64,
) -> Result<space_members::Model, SpaceError> {
match member(state, user_id, space_id, user_id).await? {
Some(member) if member.status() == models::space::MemberStatus::Active => Ok(member),
_ => Err(SpaceError::Forbidden),
}
}
pub async fn list_members(
state: &AppState,
_actor: i64,
space_id: Uuid,
include_inactive: bool,
include_banned: bool,
) -> Result<Vec<space_members::Model>, SpaceError> {
let mut query = Members::find().filter(space_members::Column::OrganizationId.eq(space_id));
if !include_inactive {
query = query.filter(space_members::Column::LeftAt.is_null());
}
if !include_banned {
query = query.filter(space_members::Column::BannedAt.is_null());
}
Ok(query.all(state.database.read()).await?)
}
pub async fn list_user_spaces(
state: &AppState,
_actor: i64,
user_id: i64,
) -> Result<Vec<spaces::Model>, SpaceError> {
let space_ids: Vec<Uuid> = Members::find()
.filter(space_members::Column::UserId.eq(user_id))
.filter(space_members::Column::LeftAt.is_null())
.filter(space_members::Column::BannedAt.is_null())
.select_only()
.column(space_members::Column::OrganizationId)
.into_tuple()
.all(state.database.read())
.await?;
spaces_by_ids(state, space_ids).await
}
/// Public directory (ACTIVE, joinable). Presentation fields come from the extras row.
pub async fn list_public_spaces(
state: &AppState,
query: &str,
) -> Result<Vec<spaces::Model>, SpaceError> {
use sea_orm::Condition;
let mut select = Spaces::find()
.filter(spaces::Column::Status.eq(SpaceStatus::Active))
.filter(spaces::Column::DeletedAt.is_null())
.filter(
spaces::Column::JoinPolicy.is_in([SpaceJoinPolicy::Open, SpaceJoinPolicy::Approval]),
);
let needle = query.trim();
if !needle.is_empty() {
select = select.filter(
Condition::any()
.add(spaces::Column::Name.contains(needle))
.add(spaces::Column::Slug.contains(needle)),
);
}
Ok(select.limit(1_000).all(state.database.read()).await?)
}
async fn spaces_by_ids(state: &AppState, ids: Vec<Uuid>) -> Result<Vec<spaces::Model>, SpaceError> {
if ids.is_empty() {
return Ok(Vec::new());
}
Ok(Spaces::find()
.filter(spaces::Column::Id.is_in(ids))
.filter(spaces::Column::DeletedAt.is_null())
.all(state.database.read())
.await?)
}
pub async fn account_created_at(
state: &AppState,
user_id: i64,
) -> Result<Option<OffsetDateTime>, SpaceError> {
let user = client(state)?.get_authority_user(user_id).await?;
let Some(created) = user.created_at else {
return Ok(None);
};
Ok(OffsetDateTime::from_unix_timestamp(created.seconds)
.ok()
.map(|row| {
row.replace_nanosecond(created.nanos.clamp(0, 999_999_999) as u32)
.unwrap_or(row)
}))
}
pub async fn mfa_enabled(state: &AppState, user_id: i64) -> Result<bool, SpaceError> {
let status = client(state)?.get_user_status(user_id).await?;
Ok(status.active_mfa_factors > 0)
}
pub async fn has_verified_email(state: &AppState, user_id: i64) -> Result<bool, SpaceError> {
let status = client(state)?.get_user_status(user_id).await?;
Ok(status.email_verified_at.is_some())
}
pub async fn permissions(
state: &AppState,
space_id: Uuid,
user_id: i64,
actions: &[String],
) -> Result<Vec<bool>, SpaceError> {
let service = SpaceService::new(state.clone());
let checks = service
.check_permissions(user_id, space_id, user_id, actions, None)
.await?;
Ok(checks.into_iter().map(|check| check.allowed).collect())
}
pub async fn upsert_extras(
state: &AppState,
space_id: Uuid,
avatar_url: Option<Option<String>>,
welcome_object: Option<Option<Uuid>>,
banner_url: Option<Option<String>>,
banner_width: Option<Option<i32>>,
banner_height: Option<Option<i32>>,
invite_banner_url: Option<Option<String>>,
invite_banner_width: Option<Option<i32>>,
invite_banner_height: Option<Option<i32>>,
embed_splash_url: Option<Option<String>>,
embed_splash_width: Option<Option<i32>>,
embed_splash_height: Option<Option<i32>>,
invite_sort_url_hash: Option<Option<String>>,
) -> Result<space_extras::Model, SpaceError> {
let now = OffsetDateTime::now_utc();
let existing = extras(state, space_id).await?;
let row = match existing {
Some(row) => {
let mut active: space_extras::ActiveModel = row.into();
if let Some(value) = avatar_url {
active.avatar_url = Set(value);
}
if let Some(value) = welcome_object {
active.welcome_object = Set(value);
}
if let Some(value) = banner_url {
active.banner_url = Set(value);
}
if let Some(value) = banner_width {
active.banner_width = Set(value);
}
if let Some(value) = banner_height {
active.banner_height = Set(value);
}
if let Some(value) = invite_banner_url {
active.invite_banner_url = Set(value);
}
if let Some(value) = invite_banner_width {
active.invite_banner_width = Set(value);
}
if let Some(value) = invite_banner_height {
active.invite_banner_height = Set(value);
}
if let Some(value) = embed_splash_url {
active.embed_splash_url = Set(value);
}
if let Some(value) = embed_splash_width {
active.embed_splash_width = Set(value);
}
if let Some(value) = embed_splash_height {
active.embed_splash_height = Set(value);
}
if let Some(value) = invite_sort_url_hash {
active.invite_sort_url_hash = Set(value);
}
active.updated_at = Set(now);
active.update(state.database.write()).await?
}
None => {
space_extras::ActiveModel {
space_id: Set(space_id),
avatar_url: Set(avatar_url.unwrap_or(None)),
invite_sort_url_hash: Set(invite_sort_url_hash.unwrap_or(None)),
banner_url: Set(banner_url.unwrap_or(None)),
banner_width: Set(banner_width.unwrap_or(None)),
banner_height: Set(banner_height.unwrap_or(None)),
invite_banner_url: Set(invite_banner_url.unwrap_or(None)),
invite_banner_width: Set(invite_banner_width.unwrap_or(None)),
invite_banner_height: Set(invite_banner_height.unwrap_or(None)),
embed_splash_url: Set(embed_splash_url.unwrap_or(None)),
embed_splash_width: Set(embed_splash_width.unwrap_or(None)),
embed_splash_height: Set(embed_splash_height.unwrap_or(None)),
welcome_object: Set(welcome_object.unwrap_or(None)),
created_at: Set(now),
updated_at: Set(now),
}
.insert(state.database.write())
.await?
}
};
Ok(row)
}
pub async fn upsert_member_extras(
state: &AppState,
space_id: Uuid,
user_id: i64,
invited_at: Option<OffsetDateTime>,
) -> Result<(), SpaceError> {
let now = OffsetDateTime::now_utc();
let existing = member_extras(state, space_id, user_id).await?;
match existing {
Some(row) => {
let mut active: space_member_extras::ActiveModel = row.into();
if invited_at.is_some() {
active.invited_at = Set(invited_at);
}
active.updated_at = Set(now);
active.update(state.database.write()).await?;
}
None => {
space_member_extras::ActiveModel {
space_id: Set(space_id),
user_id: Set(user_id),
invited_at: Set(invited_at),
created_at: Set(now),
updated_at: Set(now),
}
.insert(state.database.write())
.await?;
}
}
Ok(())
}
pub async fn remove_extras(state: &AppState, space_id: Uuid) -> Result<(), SpaceError> {
SpaceExtras::delete_by_id(space_id)
.exec(state.database.write())
.await?;
MemberExtras::delete_many()
.filter(space_member_extras::Column::SpaceId.eq(space_id))
.exec(state.database.write())
.await?;
Ok(())
}
pub async fn remove_member_extras(
state: &AppState,
space_id: Uuid,
user_id: i64,
) -> Result<(), SpaceError> {
MemberExtras::delete_by_id((space_id, user_id))
.exec(state.database.write())
.await?;
Ok(())
}
pub fn db_error(error: DbErr) -> SpaceError {
SpaceError::from(error)
}