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::{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: i64, pub avatar_url: Option, 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, } #[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, pub height: Option, pub created_at: OffsetDateTime, } impl From 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, pub height: Option, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct ListLimitQuery { pub limit: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ProfileDetailDto { pub user_id: i64, pub username: String, pub display_name: String, pub avatar_url: String, #[serde(skip_serializing_if = "Option::is_none")] pub visibility: Option, pub real_name: Option, pub bio: Option, pub pronouns: Option, pub website: Option, pub location: Option, pub company: Option, pub banner_url: Option, pub accent_color: Option, #[serde(skip_serializing_if = "Option::is_none")] pub show_email: Option, #[serde(skip_serializing_if = "Option::is_none")] pub show_location: Option, #[serde(skip_serializing_if = "Option::is_none")] pub show_real_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub show_activity: Option, pub updated_at: OffsetDateTime, } impl UserService { pub async fn my_spaces(&self, uid: i64, q: PageQuery) -> Result, UserError> { let (limit, offset) = q.bounds()?; let mut space_rows = self.access.user_spaces(uid).await?; space_rows.sort_by(|a, b| a.slug().cmp(b.slug())); let total = space_rows.len() as u64; let mut out = Vec::new(); for space in space_rows .into_iter() .skip(offset as usize) .take(limit as usize) { let perms = space.permissions(uid).await?; let avatar_url = space.avatar_url().await?; let members_count = space.member_count().await?; let space_dto = SpaceDto { id: space.id(), name: space.name().to_owned(), owner_uid: space.owner(), avatar_url, is_private: space.is_private(), members_count, created_at: space.created_at(), }; out.push(MySpaceDto { space: space_dto, permissions: perms, }); } Ok(Page::new(out, total, limit, offset)) } pub async fn list_memes( &self, uid: i64, limit: Option, ) -> Result, 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.database.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: i64, meme_id: Uuid, ) -> Result { let row = user_favorite_memes::Entity::find_by_id(meme_id) .one(self.database.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: i64, name: &str, ) -> Result, 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.database.read()) .await?; Ok(existing) } #[tracing::instrument(name = "user.create_meme", skip_all, fields(uid = %uid))] pub async fn create_meme(&self, uid: i64, cmd: UpsertMemeCmd) -> Result { 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: i64, meme_id: Uuid, cmd: UpsertMemeCmd, ) -> Result { 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.database.write()).await?; Ok(MemeDto::from(model)) } async fn upsert_meme_row( &self, uid: i64, cmd: UpsertMemeCmd, ) -> Result { 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.database.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.database.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: i64, meme_id: Uuid) -> Result<(), UserError> { self.owned_meme(uid, meme_id).await?; user_favorite_memes::Entity::delete_by_id(meme_id) .exec(self.database.write()) .await?; Ok(()) } pub async fn profile_detail( &self, viewer: Option, target_uid: i64, ) -> Result { let local = user::Entity::find_by_id(target_uid) .one(self.database.read()) .await?; let public = auth::users::get_public_user(&self.accounts, target_uid) .await? .ok_or(UserError::NotFound)?; let status = self .accounts .get_user_status(target_uid) .await .map_err(|error| UserError::Internal(error.to_string()))?; let is_owner = viewer == Some(target_uid); if !status.allow_use && !is_owner { return Err(UserError::NotFound); } let visibility = local .as_ref() .map(|row| row.visibility) .unwrap_or(ProfileVisibility::Public); let can_view = match 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.database.read()) .await?; let display_name = if public.displayname.trim().is_empty() { public.username.clone() } else { public.displayname.clone() }; let local_avatar = local .as_ref() .map(|row| row.local_avatar_url.as_str()) .unwrap_or_default(); let avatar_raw = if local_avatar.is_empty() { public.avatar_url.clone() } else { local_avatar.to_string() }; let mut dto = ProfileDetailDto { user_id: target_uid, username: public.username.clone(), display_name, avatar_url: space::services::dto::resolve_user_avatar_url(target_uid, &avatar_raw), visibility: if is_owner { Some(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: local .as_ref() .map(|row| row.updated_at) .unwrap_or_else(OffsetDateTime::now_utc), }; if let Some(p) = profile { let gate = |flag: bool, v: Option| -> Option { 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: i64, target: i64, ) -> Result { let viewer_spaces = service.access.user_spaces(viewer).await?; if viewer_spaces.is_empty() { return Ok(false); } let target_spaces = service.access.user_spaces(target).await?; let target_ids: std::collections::HashSet = target_spaces.into_iter().map(|space| space.id()).collect(); Ok(viewer_spaces .iter() .any(|space| target_ids.contains(&space.id()))) }