8fefadc
use models::space::{space_group_permissions, space_member_groups, space_members, spaces};
use models::users::user::{self, ProfileVisibility};
use models::users::{user_favorite_memes, user_profiles};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use crate::errors::UserError;
use crate::services::service::UserService;
use crate::share::page::PageQuery;
pub const OWNER_PERMISSIONS: [&str; 9] = [
"view_space",
"read",
"message",
"manage_members",
"manage_roles",
"manage_invites",
"manage_bans",
"manage_channels",
"manage_overwrites",
];
pub const MAX_MEME_NAME: usize = 64;
pub const MAX_MEME_URL: usize = 2048;
pub const MAX_MEME_CONTENT_TYPE: usize = 128;
pub const DEFAULT_LIST_LIMIT: u64 = 50;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SpaceDto {
pub id: Uuid,
pub name: String,
pub owner_uid: Uuid,
pub avatar_url: Option<String>,
pub is_private: bool,
pub members_count: i64,
pub created_at: OffsetDateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MySpaceDto {
pub space: SpaceDto,
pub permissions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MemeDto {
pub id: Uuid,
pub name: String,
pub content_type: String,
pub url: String,
pub size: i64,
pub width: Option<i32>,
pub height: Option<i32>,
pub created_at: OffsetDateTime,
}
impl From<user_favorite_memes::Model> for MemeDto {
fn from(m: user_favorite_memes::Model) -> Self {
Self {
id: m.id,
name: m.name,
content_type: m.content_type,
url: m.url,
size: m.size,
width: m.width,
height: m.height,
created_at: m.created_at,
}
}
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpsertMemeCmd {
pub name: String,
pub content_type: String,
pub url: String,
pub size: i64,
pub width: Option<i32>,
pub height: Option<i32>,
}
#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
pub struct ListLimitQuery {
pub limit: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProfileDetailDto {
pub user_id: Uuid,
pub username: String,
pub display_name: String,
pub avatar_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub visibility: Option<ProfileVisibility>,
pub real_name: Option<String>,
pub bio: Option<String>,
pub pronouns: Option<String>,
pub website: Option<String>,
pub location: Option<String>,
pub company: Option<String>,
pub banner_url: Option<String>,
pub accent_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub show_email: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub show_location: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub show_real_name: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub show_activity: Option<bool>,
pub updated_at: OffsetDateTime,
}
impl UserService {
pub async fn my_spaces(&self, uid: Uuid, q: PageQuery) -> Result<Vec<MySpaceDto>, UserError> {
let limit = q.limit();
let offset = q.offset();
let members = space_members::Entity::find()
.filter(space_members::Column::UserId.eq(uid))
.filter(space_members::Column::Status.eq(space_members::MemberStatus::Active))
.all(self.db().read())
.await?;
let space_ids: Vec<Uuid> = members.into_iter().map(|m| m.space_id).collect();
if space_ids.is_empty() {
return Ok(Vec::new());
}
let space_rows = spaces::Entity::find()
.filter(spaces::Column::Id.is_in(space_ids))
.order_by_asc(spaces::Column::LowerName)
.limit(limit)
.offset(offset)
.all(self.db().read())
.await?;
let mut out = Vec::with_capacity(space_rows.len());
for space in space_rows {
let perms = if space.owner_id == uid {
OWNER_PERMISSIONS.iter().map(|s| s.to_string()).collect()
} else {
let member_groups = space_member_groups::Entity::find()
.filter(space_member_groups::Column::SpaceId.eq(space.id))
.filter(space_member_groups::Column::UserId.eq(uid))
.all(self.db().read())
.await?;
let group_ids: Vec<Uuid> =
member_groups.into_iter().map(|mg| mg.group_id).collect();
let mut granted = if group_ids.is_empty() {
Vec::new()
} else {
space_group_permissions::Entity::find()
.filter(space_group_permissions::Column::GroupId.is_in(group_ids))
.all(self.db().read())
.await?
.into_iter()
.map(|p| p.permission)
.collect()
};
granted.sort();
granted.dedup();
granted
};
let avatar_url = space::services::spaces::resolve_space_image_url(
&space.lower_name,
"avatar",
space.avatar_url,
);
let space_dto = SpaceDto {
id: space.id,
name: space.name,
owner_uid: space.owner_id,
avatar_url,
is_private: space.is_private,
members_count: space.nums_members,
created_at: space.created_at,
};
out.push(MySpaceDto {
space: space_dto,
permissions: perms,
});
}
Ok(out)
}
pub async fn list_memes(
&self,
uid: Uuid,
limit: Option<u64>,
) -> Result<Vec<MemeDto>, UserError> {
let lim = limit.unwrap_or(DEFAULT_LIST_LIMIT).clamp(1, 50);
let rows = user_favorite_memes::Entity::find()
.filter(user_favorite_memes::Column::UserId.eq(uid))
.order_by_desc(user_favorite_memes::Column::CreatedAt)
.limit(Some(lim))
.all(self.db().read())
.await?;
Ok(rows.into_iter().map(MemeDto::from).collect())
}
fn validate_meme_cmd(cmd: &UpsertMemeCmd) -> Result<(), UserError> {
if cmd.name.is_empty() || cmd.name.len() > MAX_MEME_NAME {
return Err(UserError::BadRequest(format!(
"meme name length must be 1..={MAX_MEME_NAME}"
)));
}
if cmd.content_type.len() > MAX_MEME_CONTENT_TYPE {
return Err(UserError::BadRequest(format!(
"content_type length must be <={MAX_MEME_CONTENT_TYPE}"
)));
}
if cmd.url.len() > MAX_MEME_URL {
return Err(UserError::BadRequest(format!(
"url length must be <={MAX_MEME_URL}"
)));
}
Ok(())
}
async fn owned_meme(
&self,
uid: Uuid,
meme_id: Uuid,
) -> Result<user_favorite_memes::Model, UserError> {
let row = user_favorite_memes::Entity::find_by_id(meme_id)
.one(self.db().read())
.await?
.ok_or(UserError::NotFound)?;
if row.user_id != uid {
return Err(UserError::Forbidden);
}
Ok(row)
}
async fn find_meme_by_name(
&self,
uid: Uuid,
name: &str,
) -> Result<Option<user_favorite_memes::Model>, UserError> {
let existing = user_favorite_memes::Entity::find()
.filter(user_favorite_memes::Column::UserId.eq(uid))
.filter(user_favorite_memes::Column::Name.eq(name))
.one(self.db().read())
.await?;
Ok(existing)
}
#[tracing::instrument(name = "user.create_meme", skip_all, fields(uid = %uid))]
pub async fn create_meme(&self, uid: Uuid, cmd: UpsertMemeCmd) -> Result<MemeDto, UserError> {
Self::validate_meme_cmd(&cmd)?;
let existing = self.find_meme_by_name(uid, &cmd.name).await?;
if existing.is_some() {
return Err(UserError::Conflict("meme name already exists".to_string()));
}
let model = self.upsert_meme_row(uid, cmd).await?;
Ok(MemeDto::from(model))
}
#[tracing::instrument(
name = "user.update_meme",
skip_all,
fields(uid = %uid, meme_id = %meme_id)
)]
pub async fn update_meme(
&self,
uid: Uuid,
meme_id: Uuid,
cmd: UpsertMemeCmd,
) -> Result<MemeDto, UserError> {
Self::validate_meme_cmd(&cmd)?;
let row = self.owned_meme(uid, meme_id).await?;
let mut am: user_favorite_memes::ActiveModel = row.into();
am.name = Set(cmd.name);
am.content_type = Set(cmd.content_type);
am.url = Set(cmd.url);
am.size = Set(cmd.size);
am.width = Set(cmd.width);
am.height = Set(cmd.height);
let model = am.update(self.db().write()).await?;
Ok(MemeDto::from(model))
}
async fn upsert_meme_row(
&self,
uid: Uuid,
cmd: UpsertMemeCmd,
) -> Result<user_favorite_memes::Model, UserError> {
let existing = self.find_meme_by_name(uid, &cmd.name).await?;
let now = OffsetDateTime::now_utc();
let model = match existing {
Some(row) => {
let mut active: user_favorite_memes::ActiveModel = row.into();
active.content_type = Set(cmd.content_type);
active.url = Set(cmd.url);
active.size = Set(cmd.size);
active.width = Set(cmd.width);
active.height = Set(cmd.height);
active.update(self.db().write()).await?
}
None => {
let active = user_favorite_memes::ActiveModel {
id: Set(Uuid::now_v7()),
user_id: Set(uid),
name: Set(cmd.name),
content_type: Set(cmd.content_type),
url: Set(cmd.url),
size: Set(cmd.size),
width: Set(cmd.width),
height: Set(cmd.height),
created_at: Set(now),
};
active.insert(self.db().write()).await?
}
};
Ok(model)
}
#[tracing::instrument(
name = "user.delete_meme",
skip_all,
fields(uid = %uid, meme_id = %meme_id)
)]
pub async fn delete_meme(&self, uid: Uuid, meme_id: Uuid) -> Result<(), UserError> {
self.owned_meme(uid, meme_id).await?;
user_favorite_memes::Entity::delete_by_id(meme_id)
.exec(self.db().write())
.await?;
Ok(())
}
pub async fn profile_detail(
&self,
viewer: Option<Uuid>,
target_uid: Uuid,
) -> Result<ProfileDetailDto, UserError> {
let user = user::Entity::find_by_id(target_uid)
.one(self.db().read())
.await?
.ok_or(UserError::NotFound)?;
let is_owner = viewer == Some(target_uid);
if !user.is_active && !is_owner {
return Err(UserError::NotFound);
}
let can_view = match user.visibility {
ProfileVisibility::Public => true,
ProfileVisibility::RegisteredOnly => viewer.is_some(),
ProfileVisibility::MembersOnly => matches!(
viewer,
Some(viewer_uid)
if is_owner || shares_space_with(self, viewer_uid, target_uid).await?
),
ProfileVisibility::Private => is_owner,
};
if !can_view {
return Err(UserError::Forbidden);
}
let profile = user_profiles::Entity::find_by_id(target_uid)
.one(self.db().read())
.await?;
let mut dto = ProfileDetailDto {
user_id: user.uid,
username: user.username,
display_name: user.display_name,
avatar_url: space::services::spaces::resolve_user_avatar_url(
user.uid,
&user.avatar_url,
),
visibility: if is_owner {
Some(user.visibility)
} else {
None
},
real_name: None,
bio: None,
pronouns: None,
website: None,
location: None,
company: None,
banner_url: None,
accent_color: None,
show_email: if is_owner { Some(false) } else { None },
show_location: if is_owner { Some(false) } else { None },
show_real_name: if is_owner { Some(false) } else { None },
show_activity: if is_owner { Some(false) } else { None },
updated_at: user.updated_at,
};
if let Some(p) = profile {
let gate = |flag: bool, v: Option<String>| -> Option<String> {
if is_owner || flag { v } else { None }
};
let emit_activity = is_owner || p.show_activity;
dto.real_name = gate(p.show_real_name, p.real_name);
dto.bio = gate(emit_activity, p.bio);
dto.pronouns = gate(emit_activity, p.pronouns);
dto.website = gate(emit_activity, p.website);
dto.location = gate(p.show_location, p.location);
dto.company = gate(emit_activity, p.company);
dto.banner_url = gate(emit_activity, p.banner_url);
dto.accent_color = gate(emit_activity, p.accent_color);
dto.show_email = if is_owner { Some(p.show_email) } else { None };
dto.show_location = if is_owner {
Some(p.show_location)
} else {
None
};
dto.show_real_name = if is_owner {
Some(p.show_real_name)
} else {
None
};
dto.show_activity = if is_owner {
Some(p.show_activity)
} else {
None
};
dto.updated_at = p.updated_at;
}
Ok(dto)
}
}
async fn shares_space_with(
service: &UserService,
viewer: Uuid,
target: Uuid,
) -> Result<bool, UserError> {
let viewer_rows = space_members::Entity::find()
.filter(space_members::Column::UserId.eq(viewer))
.filter(space_members::Column::Status.eq(space_members::MemberStatus::Active))
.all(service.db().read())
.await?;
let space_ids: Vec<Uuid> = viewer_rows.into_iter().map(|m| m.space_id).collect();
if space_ids.is_empty() {
return Ok(false);
}
let shared = space_members::Entity::find()
.filter(space_members::Column::SpaceId.is_in(space_ids))
.filter(space_members::Column::UserId.eq(target))
.filter(space_members::Column::Status.eq(space_members::MemberStatus::Active))
.one(service.db().read())
.await?;
Ok(shared.is_some())
}