Skip to content
Clone
163952e
548 lines 18.7 KB Rust
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, MemberStatus, MessageNotifications};
use models::space::spaces::{self, AllowJoinLevel};
use proto::auth as proto;
use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::errors::SpaceError;
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()))
}
fn timestamp(value: &Option<prost_types::Timestamp>) -> Option<OffsetDateTime> {
let value = value.as_ref()?;
OffsetDateTime::from_unix_timestamp(value.seconds)
.ok()?
.replace_nanosecond(value.nanos.clamp(0, 999_999_999) as u32)
.ok()
}
fn join_policy(value: i32) -> &'static str {
match proto::v1::OrganizationJoinPolicy::try_from(value)
.unwrap_or(proto::v1::OrganizationJoinPolicy::InviteOnly)
{
proto::v1::OrganizationJoinPolicy::Open => "open",
proto::v1::OrganizationJoinPolicy::Approval => "approval",
proto::v1::OrganizationJoinPolicy::Private => "private",
_ => "invite_only",
}
}
fn status_of_member(member: &proto::v1::OrganizationMemberInfo) -> MemberStatus {
if member.banned_at.is_some() {
MemberStatus::Banned
} else if member.left_at.is_some() {
MemberStatus::Left
} else {
MemberStatus::Active
}
}
fn non_empty(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
pub fn space_from_info(
info: &proto::v1::OrganizationInfo,
extras: Option<&space_extras::Model>,
) -> spaces::Model {
let id = Uuid::parse_str(info.id.trim()).unwrap_or_default();
let now = OffsetDateTime::now_utc();
let policy = join_policy(info.join_policy);
let status = proto::v1::OrganizationStatus::try_from(info.status)
.unwrap_or(proto::v1::OrganizationStatus::Active);
spaces::Model {
id,
lower_name: info.slug.clone(),
name: info.name.clone(),
description: non_empty(&info.description),
owner_id: info.owner_user_id,
avatar_url: extras
.and_then(|row| row.avatar_url.clone())
.or_else(|| non_empty(&info.avatar_url)),
is_private: policy == "private",
is_active: status == proto::v1::OrganizationStatus::Active,
allow_join: info.allow_join,
allow_invite: info.allow_invite,
invite_sort_url_hash: extras.and_then(|row| row.invite_sort_url_hash.clone()),
nums_members: info.member_count as i64,
banner_url: extras.and_then(|row| row.banner_url.clone()),
banner_width: extras.and_then(|row| row.banner_width),
banner_height: extras.and_then(|row| row.banner_height),
invite_banner_url: extras.and_then(|row| row.invite_banner_url.clone()),
invite_banner_width: extras.and_then(|row| row.invite_banner_width),
invite_banner_height: extras.and_then(|row| row.invite_banner_height),
embed_splash_url: extras.and_then(|row| row.embed_splash_url.clone()),
embed_splash_width: extras.and_then(|row| row.embed_splash_width),
embed_splash_height: extras.and_then(|row| row.embed_splash_height),
welcome_message: non_empty(&info.welcome_message),
allow_join_level: AllowJoinLevel::from_i16(info.allow_join_level as i16),
member_must_enable_2fa: info.member_must_enable_2fa,
welcome_object: extras.and_then(|row| row.welcome_object),
join_policy: policy.to_string(),
require_email_verified: info.require_email_verified,
require_mfa: info.require_mfa,
version: info.version,
created_at: timestamp(&info.created_at).unwrap_or(now),
updated_at: timestamp(&info.updated_at).unwrap_or(now),
}
}
pub fn member_from_info(
info: &proto::v1::OrganizationMemberInfo,
extras: Option<&space_member_extras::Model>,
) -> space_members::Model {
let now = OffsetDateTime::now_utc();
let joined_at = timestamp(&info.joined_at).unwrap_or(now);
space_members::Model {
space_id: Uuid::parse_str(info.organization_id.trim()).unwrap_or_default(),
user_id: info.user_id,
alias_name: non_empty(&info.alias_name),
alias_avatar_url: non_empty(&info.alias_avatar_url),
status: status_of_member(info),
last_active_at: timestamp(&info.last_active_at),
left_at: timestamp(&info.left_at),
invited_at: extras.and_then(|row| row.invited_at),
invited_by: (info.invited_by > 0).then_some(info.invited_by),
joined_at,
created_at: extras.map(|row| row.created_at).unwrap_or(joined_at),
updated_at: extras.map(|row| row.updated_at),
message_notifications: MessageNotifications::from_str(&info.message_notifications),
muted: info.muted,
mute_until: timestamp(&info.mute_until),
mobile_push: info.mobile_push,
suppress_everyone: info.suppress_everyone,
suppress_roles: info.suppress_roles,
hide_muted_channels: info.hide_muted_channels,
banned_at: timestamp(&info.banned_at),
ban_reason: non_empty(&info.ban_reason),
ban_expires_at: timestamp(&info.ban_expires_at),
ban_allow_speak: info.ban_allow_speak,
ban_allow_push_repo: info.ban_allow_push_repo,
ban_allow_comment_any: info.ban_allow_comment_any,
}
}
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_by_id(
state: &AppState,
actor: i64,
space_id: Uuid,
) -> Result<spaces::Model, SpaceError> {
let response = client(state)?
.get_organization(actor, &space_id.to_string(), "")
.await?;
let info = response.organization.ok_or(SpaceError::NotFound)?;
let extras = extras(state, space_id).await?;
Ok(space_from_info(&info, extras.as_ref()))
}
pub async fn space_by_slug(
state: &AppState,
actor: i64,
slug: &str,
) -> Result<spaces::Model, SpaceError> {
let response = client(state)?.get_organization(actor, "", slug).await?;
let info = response.organization.ok_or(SpaceError::NotFound)?;
let id = Uuid::parse_str(info.id.trim()).unwrap_or_default();
let extras = extras(state, id).await?;
Ok(space_from_info(&info, extras.as_ref()))
}
pub async fn member(
state: &AppState,
actor: i64,
space_id: Uuid,
user_id: i64,
) -> Result<Option<space_members::Model>, SpaceError> {
let client = client(state)?;
let info = match client
.get_member(actor, &space_id.to_string(), user_id)
.await
{
Ok(Some(member)) => member,
Ok(None) => return Ok(None),
Err(export::KenAuthError::Rejected(message))
if message.to_lowercase().contains("forbidden")
|| message.to_lowercase().contains("permission") =>
{
return Ok(None);
}
Err(error) => return Err(error.into()),
};
let extras = member_extras(state, space_id, user_id).await?;
Ok(Some(member_from_info(&info, extras.as_ref())))
}
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 == 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 client = client(state)?;
let mut members = Vec::new();
let mut token = String::new();
loop {
let response = client
.list_members(
actor,
&space_id.to_string(),
include_inactive,
include_banned,
proto::common::v1::PageRequest {
page_size: 100,
page_token: token.clone(),
},
)
.await?;
members.extend(response.members);
let next = response
.page
.map(|page| page.next_page_token)
.unwrap_or_default();
if next.is_empty() {
break;
}
token = next;
}
let user_ids: Vec<i64> = members.iter().map(|member| member.user_id).collect();
let extras = if user_ids.is_empty() {
Vec::new()
} else {
MemberExtras::find()
.filter(space_member_extras::Column::SpaceId.eq(space_id))
.filter(space_member_extras::Column::UserId.is_in(user_ids))
.all(state.database.read())
.await?
};
let mut extras: std::collections::HashMap<i64, space_member_extras::Model> =
extras.into_iter().map(|row| (row.user_id, row)).collect();
Ok(members
.iter()
.map(|info| member_from_info(info, extras.remove(&info.user_id).as_ref()))
.collect())
}
pub async fn list_user_spaces(
state: &AppState,
actor: i64,
user_id: i64,
) -> Result<Vec<spaces::Model>, SpaceError> {
let client = client(state)?;
let mut organizations = Vec::new();
let mut token = String::new();
loop {
let response = client
.list_organizations(
actor,
user_id,
false,
proto::common::v1::PageRequest {
page_size: 100,
page_token: token.clone(),
},
)
.await?;
organizations.extend(response.organizations);
let next = response
.page
.map(|page| page.next_page_token)
.unwrap_or_default();
if next.is_empty() {
break;
}
token = next;
}
let ids: Vec<Uuid> = organizations
.iter()
.filter_map(|info| Uuid::parse_str(info.id.trim()).ok())
.collect();
let extras = if ids.is_empty() {
Vec::new()
} else {
SpaceExtras::find()
.filter(space_extras::Column::SpaceId.is_in(ids))
.all(state.database.read())
.await?
};
let mut extras: std::collections::HashMap<Uuid, space_extras::Model> =
extras.into_iter().map(|row| (row.space_id, row)).collect();
Ok(organizations
.iter()
.map(|info| {
let id = Uuid::parse_str(info.id.trim()).unwrap_or_default();
space_from_info(info, extras.remove(&id).as_ref())
})
.collect())
}
/// Public directory (ACTIVE, non-PRIVATE). Authority is KenOAuth; the local
/// extras row only contributes presentation fields.
pub async fn list_public_spaces(
state: &AppState,
query: &str,
) -> Result<Vec<spaces::Model>, SpaceError> {
let client = client(state)?;
let mut organizations = Vec::new();
let mut token = String::new();
loop {
let response = client
.list_public_organizations(
query,
proto::common::v1::PageRequest {
page_size: 100,
page_token: token.clone(),
},
)
.await?;
organizations.extend(response.organizations);
let next = response
.page
.map(|page| page.next_page_token)
.unwrap_or_default();
if next.is_empty() || organizations.len() >= 1_000 {
break;
}
token = next;
}
let ids: Vec<Uuid> = organizations
.iter()
.filter_map(|info| Uuid::parse_str(info.id.trim()).ok())
.collect();
let extras = if ids.is_empty() {
Vec::new()
} else {
SpaceExtras::find()
.filter(space_extras::Column::SpaceId.is_in(ids))
.all(state.database.read())
.await?
};
let mut extras: std::collections::HashMap<Uuid, space_extras::Model> =
extras.into_iter().map(|row| (row.space_id, row)).collect();
Ok(organizations
.iter()
.map(|info| {
let id = Uuid::parse_str(info.id.trim()).unwrap_or_default();
space_from_info(info, extras.remove(&id).as_ref())
})
.collect())
}
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?;
Ok(timestamp(&user.created_at))
}
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 client = client(state)?;
let response = client
.check_permissions(user_id, &space_id.to_string(), user_id, actions.to_vec())
.await?;
Ok(response.allowed)
}
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)
}