Skip to content
Clone
acbf7ea
428 lines 15.3 KB Rust
use std::collections::HashMap;
use models::space::space_member_groups;
use models::space::space_members::{self, MemberStatus, MessageNotifications};
use models::space::spaces;
use models::users::user;
use sea_orm::sea_query::Expr;
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter,
QueryOrder, QuerySelect, Set, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::authority;
use crate::errors::SpaceError;
use crate::services::service::SpaceService;
use crate::services::spaces::{PageQuery, UserDto};
use crate::share::authz::{MANAGE_MEMBERS, assert_not_banned, require_perm};
use crate::share::lookup::lookup_space_by_name;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MemberDto {
pub space_id: Uuid,
pub user: Option<UserDto>,
pub alias_name: Option<String>,
pub alias_avatar_url: Option<String>,
pub status: MemberStatus,
pub last_active_at: Option<OffsetDateTime>,
pub left_at: Option<OffsetDateTime>,
pub invited_at: Option<OffsetDateTime>,
pub invited_by: Option<UserDto>,
pub joined_at: OffsetDateTime,
pub created_at: OffsetDateTime,
pub updated_at: Option<OffsetDateTime>,
pub message_notifications: MessageNotifications,
pub muted: bool,
pub mute_until: Option<OffsetDateTime>,
pub mobile_push: bool,
pub suppress_everyone: bool,
pub suppress_roles: bool,
pub hide_muted_channels: bool,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct AddMemberCmd {
pub user_id: i64,
#[serde(default)]
pub alias_name: Option<String>,
#[serde(default)]
pub alias_avatar_url: Option<String>,
#[serde(default)]
pub message_notifications: Option<MessageNotifications>,
#[serde(default)]
pub muted: Option<bool>,
#[serde(
default,
deserialize_with = "export::serde_datetime::deserialize_opt_datetime"
)]
pub mute_until: Option<OffsetDateTime>,
#[serde(default)]
pub mobile_push: Option<bool>,
#[serde(default)]
pub suppress_everyone: Option<bool>,
#[serde(default)]
pub suppress_roles: Option<bool>,
#[serde(default)]
pub hide_muted_channels: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateMemberCmd {
#[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
pub alias_name: Option<Option<String>>,
#[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
pub alias_avatar_url: Option<Option<String>>,
#[serde(default)]
pub message_notifications: Option<MessageNotifications>,
#[serde(default)]
pub muted: Option<bool>,
#[serde(
default,
deserialize_with = "export::serde_datetime::deserialize_opt_opt_datetime"
)]
pub mute_until: Option<Option<OffsetDateTime>>,
#[serde(default)]
pub mobile_push: Option<bool>,
#[serde(default)]
pub suppress_everyone: Option<bool>,
#[serde(default)]
pub suppress_roles: Option<bool>,
#[serde(default)]
pub hide_muted_channels: Option<bool>,
}
pub type MemberPage = export::pagination::Page<MemberDto>;
fn proto_timestamp(value: OffsetDateTime) -> prost_types::Timestamp {
prost_types::Timestamp {
seconds: value.unix_timestamp(),
nanos: value.nanosecond() as i32,
}
}
fn notifications_name(value: MessageNotifications) -> String {
match value {
MessageNotifications::All => "all".to_string(),
MessageNotifications::Mentions => "mentions".to_string(),
MessageNotifications::Nothing => "nothing".to_string(),
}
}
/// Presentation/notification values a member row is created with when the
/// caller does not sit in the admin "add member" flow (join-request approval,
/// invite acceptance).
fn member_dto(member: space_members::Model, users: &HashMap<i64, UserDto>) -> MemberDto {
let user = users.get(&member.user_id).cloned();
let invited_by = member.invited_by.and_then(|uid| users.get(&uid).cloned());
MemberDto {
space_id: member.space_id,
user,
alias_name: member.alias_name,
alias_avatar_url: member.alias_avatar_url,
status: member.status,
last_active_at: member.last_active_at,
left_at: member.left_at,
invited_at: member.invited_at,
invited_by,
joined_at: member.joined_at,
created_at: member.created_at,
updated_at: member.updated_at,
message_notifications: member.message_notifications,
muted: member.muted,
mute_until: member.mute_until,
mobile_push: member.mobile_push,
suppress_everyone: member.suppress_everyone,
suppress_roles: member.suppress_roles,
hide_muted_channels: member.hide_muted_channels,
}
}
impl SpaceService {
/// Auto-join every active public room of a space. Room rows are advisory:
/// a failure is logged and the membership write still stands.
pub(crate) async fn grant_public_room_memberships(&self, space_id: Uuid, user_id: i64) {
let public_rooms = models::room::rooms::Entity::find()
.filter(models::room::rooms::Column::SpaceId.eq(space_id))
.filter(models::room::rooms::Column::IsPrivate.eq(false))
.filter(models::room::rooms::Column::IsActive.eq(true))
.all(self.db().read())
.await;
let Ok(public_rooms) = public_rooms else {
tracing::warn!(%space_id, %user_id, "public room lookup failed");
return;
};
for room in &public_rooms {
let now = OffsetDateTime::now_utc();
let rm = models::room::room_members::ActiveModel {
role: Set(models::room::room_members::RoomMemberRole::Member),
room_id: Set(room.id),
user_id: Set(user_id),
nick: Set(None),
joined_at: Set(now),
left_at: Set(None),
created_at: Set(now),
updated_at: Set(Some(now)),
};
if let Err(e) = rm.insert(self.db().write()).await {
tracing::warn!(error = %e, room_id = %room.id, %user_id, "failed to create room_members record");
}
}
}
#[tracing::instrument(name = "space.add_member", skip_all, fields(caller = %caller, space = %space_name))]
pub async fn add_member(
&self,
caller: i64,
space_name: &str,
cmd: AddMemberCmd,
) -> Result<MemberDto, SpaceError> {
let space = lookup_space_by_name(self, caller, space_name).await?;
require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
assert_not_banned(&self.state, space.id, cmd.user_id).await?;
if space.member_must_enable_2fa && !authority::mfa_enabled(&self.state, cmd.user_id).await?
{
return Err(SpaceError::Forbidden);
}
if let Some(alias) = &cmd.alias_name
&& alias.chars().count() > self.config().space.max_alias_name_len
{
return Err(SpaceError::BadRequest("alias name is too long".to_string()));
}
let client = authority::client(&self.state)?;
let (_, created) = client
.add_member(caller, &space.id.to_string(), cmd.user_id, 0, Vec::new())
.await?;
if !created {
return Err(SpaceError::Conflict(
"user is already a member of this space".to_string(),
));
}
let has_prefs = cmd.alias_name.is_some()
|| cmd.alias_avatar_url.is_some()
|| cmd.message_notifications.is_some()
|| cmd.muted.is_some()
|| cmd.mute_until.is_some()
|| cmd.mobile_push.is_some()
|| cmd.suppress_everyone.is_some()
|| cmd.suppress_roles.is_some()
|| cmd.hide_muted_channels.is_some();
if has_prefs {
client
.update_member(
caller,
&space.id.to_string(),
cmd.user_id,
cmd.alias_name.map(Some),
cmd.alias_avatar_url.map(Some),
cmd.message_notifications.map(notifications_name),
cmd.muted,
cmd.mute_until.map(|value| Some(proto_timestamp(value))),
cmd.mobile_push,
cmd.suppress_everyone,
cmd.suppress_roles,
cmd.hide_muted_channels,
)
.await?;
}
self.grant_public_room_memberships(space.id, cmd.user_id)
.await;
let member = authority::member(&self.state, caller, space.id, cmd.user_id)
.await?
.ok_or(SpaceError::NotFound)?;
let user_map: HashMap<i64, UserDto> = self.load_user_briefs(&[cmd.user_id]).await?;
Ok(member_dto(member, &user_map))
}
pub async fn get_member(
&self,
caller: i64,
space_name: &str,
uid: i64,
) -> Result<MemberDto, SpaceError> {
let space = lookup_space_by_name(self, caller, space_name).await?;
if caller != uid {
require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
}
let member = authority::member(&self.state, caller, space.id, uid)
.await?
.ok_or(SpaceError::NotFound)?;
let mut uids = vec![member.user_id];
if let Some(inv_by) = member.invited_by {
uids.push(inv_by);
}
let user_map: HashMap<i64, UserDto> = self.load_user_briefs(&uids).await?;
Ok(member_dto(member, &user_map))
}
#[tracing::instrument(name = "space.update_member", skip_all, fields(caller = %caller, space = %space_name, uid = %uid))]
pub async fn update_member(
&self,
caller: i64,
space_name: &str,
uid: i64,
cmd: UpdateMemberCmd,
) -> Result<MemberDto, SpaceError> {
let space = lookup_space_by_name(self, caller, space_name).await?;
if caller != uid {
require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
}
if let Some(alias) = &cmd.alias_name
&& let Some(value) = alias
&& value.chars().count() > self.config().space.max_alias_name_len
{
return Err(SpaceError::BadRequest("alias name is too long".to_string()));
}
let client = self
.state
.kenauth
.as_ref()
.ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?;
client
.update_member(
caller,
&space.id.to_string(),
uid,
cmd.alias_name.clone(),
cmd.alias_avatar_url.clone(),
cmd.message_notifications.map(notifications_name),
cmd.muted,
cmd.mute_until.map(|value| value.map(proto_timestamp)),
cmd.mobile_push,
cmd.suppress_everyone,
cmd.suppress_roles,
cmd.hide_muted_channels,
)
.await?;
let updated = authority::member(&self.state, caller, space.id, uid)
.await?
.ok_or(SpaceError::NotFound)?;
let mut uids = vec![updated.user_id];
if let Some(inv_by) = updated.invited_by {
uids.push(inv_by);
}
let user_map: HashMap<i64, UserDto> = self.load_user_briefs(&uids).await?;
Ok(member_dto(updated, &user_map))
}
#[tracing::instrument(name = "space.leave_space", skip_all, fields(caller = %caller, space = %space_name))]
pub async fn leave_space(&self, caller: i64, space_name: &str) -> Result<(), SpaceError> {
let space = lookup_space_by_name(self, caller, space_name).await?;
if space.owner_id == caller {
return Err(SpaceError::BadRequest(
"space owner cannot leave; delete the space instead".to_string(),
));
}
let member = authority::member(&self.state, caller, space.id, caller).await?;
if !matches!(member, Some(ref member) if member.status == MemberStatus::Active) {
return Ok(());
}
authority::client(&self.state)?
.leave_organization(caller, &space.id.to_string())
.await?;
space_member_groups::Entity::delete_many()
.filter(space_member_groups::Column::SpaceId.eq(space.id))
.filter(space_member_groups::Column::UserId.eq(caller))
.exec(self.db().write())
.await?;
authority::remove_member_extras(&self.state, space.id, caller).await?;
Ok(())
}
#[tracing::instrument(name = "space.remove_member", skip_all, fields(caller = %caller, space = %space_name, uid = %uid))]
pub async fn remove_member(
&self,
caller: i64,
space_name: &str,
uid: i64,
) -> Result<(), SpaceError> {
let space = lookup_space_by_name(self, caller, space_name).await?;
require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
if caller == uid {
return Err(SpaceError::BadRequest(
"owner cannot remove themselves".to_string(),
));
}
let member = authority::member(&self.state, caller, space.id, uid).await?;
if !matches!(member, Some(ref member) if member.status == MemberStatus::Active) {
return Ok(());
}
authority::client(&self.state)?
.remove_member(caller, &space.id.to_string(), uid, "")
.await?;
space_member_groups::Entity::delete_many()
.filter(space_member_groups::Column::SpaceId.eq(space.id))
.filter(space_member_groups::Column::UserId.eq(uid))
.exec(self.db().write())
.await?;
authority::remove_member_extras(&self.state, space.id, uid).await?;
Ok(())
}
pub async fn list_members(
&self,
caller: i64,
space_name: &str,
q: PageQuery,
) -> Result<MemberPage, SpaceError> {
let space = lookup_space_by_name(self, caller, space_name).await?;
require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
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 mut rows = authority::list_members(&self.state, caller, space.id, true, true).await?;
rows.sort_by_key(|member| member.joined_at);
let total = rows.len() as i64;
let mut uids: Vec<i64> = Vec::new();
for m in &rows {
uids.push(m.user_id);
if let Some(ib) = m.invited_by {
uids.push(ib);
}
}
let user_map: HashMap<i64, UserDto> = self.load_user_briefs(&uids).await?;
let items = rows
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.map(|member| member_dto(member, &user_map))
.collect();
Ok(MemberPage {
items,
total,
limit,
offset,
})
}
}