use models::users::user::ProfileVisibility; use models::users::{user, user_profiles}; use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, Set, TransactionTrait}; use serde::{Deserialize, Serialize}; use time::{Date, OffsetDateTime}; use crate::AuthService; use crate::errors::AuthBusinessError; use crate::share::account::types::UserPublic; use crate::share::account::validate; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserMeDto { pub user_id: i64, pub username: String, pub display_name: String, pub avatar_url: String, pub is_active: bool, pub is_restricted: bool, pub prohibit_login: bool, pub visibility: ProfileVisibility, pub bot: bool, pub system: bool, pub timezone: Option, pub locale: Option, pub language: Option, pub created_at: OffsetDateTime, } #[derive(Debug, Clone, Serialize, Deserialize, Default, utoipa::ToSchema)] pub struct ProfileDto { pub real_name: Option, pub bio: Option, pub pronouns: Option, pub website: Option, pub location: Option, pub company: Option, pub date_of_birth: Option, pub show_email: bool, pub show_location: bool, pub show_real_name: bool, pub show_activity: bool, pub privacy_agreed_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default, utoipa::ToSchema)] pub struct UpdateProfileCmd { pub display_name: Option, pub locale: Option, pub language: Option, pub timezone: Option, pub real_name: Option, pub bio: Option, pub pronouns: Option, pub website: Option, pub location: Option, pub company: Option, pub date_of_birth: Option, pub show_email: Option, pub show_location: Option, pub show_real_name: Option, pub show_activity: Option, #[serde(default)] pub visibility: Option, } impl AuthService { pub async fn account_me( &self, uid: i64, ) -> Result<(UserMeDto, Option), AuthBusinessError> { db::ensure_local_user(self.db(), uid).await?; let db = self.db(); let Some(user) = user::Entity::find_by_id(uid).one(db.read()).await? else { return Err(AuthBusinessError::NotFound); }; let client = &self.accounts; let authority = client.get_authority_user(uid).await.map_err(map_kenauth)?; let public = client.get_user_public(uid).await.map_err(map_kenauth)?; let status = client.get_user_status(uid).await.map_err(map_kenauth)?; let display_name = if public.displayname.trim().is_empty() { public.username.clone() } else { public.displayname.clone() }; let avatar_raw = if user.local_avatar_url.is_empty() { public.avatar_url.clone() } else { user.local_avatar_url.clone() }; let created_at = authority .created_at .as_ref() .and_then(|value| { OffsetDateTime::from_unix_timestamp(value.seconds) .ok() .and_then(|dt| { dt.replace_nanosecond(value.nanos.clamp(0, 999_999_999) as u32) .ok() }) }) .unwrap_or_else(OffsetDateTime::now_utc); let profile = user_profiles::Entity::find_by_id(uid) .one(db.read()) .await?; let me = UserMeDto { user_id: uid, username: public.username, display_name, avatar_url: api::urls::resolve_user_avatar_url(uid, &avatar_raw), is_active: status.allow_use && status.deleted_at.is_none(), is_restricted: user.is_restricted, prohibit_login: authority.forbid_login, visibility: user.visibility, bot: public.bot, system: public.system, timezone: user.timezone, locale: user.locale, language: user.language, created_at, }; let profile = profile.map(|row| ProfileDto { real_name: row.real_name, bio: row.bio, pronouns: row.pronouns, website: row.website, location: row.location, company: row.company, date_of_birth: row.date_of_birth, show_email: row.show_email, show_location: row.show_location, show_real_name: row.show_real_name, show_activity: row.show_activity, privacy_agreed_at: row.privacy_agreed_at, }); Ok((me, profile)) } pub async fn update_profile( &self, uid: i64, cmd: UpdateProfileCmd, ) -> Result<(), AuthBusinessError> { db::ensure_local_user(self.db(), uid).await?; let db = self.db(); let Some(user) = user::Entity::find_by_id(uid).one(db.write()).await? else { return Err(AuthBusinessError::NotFound); }; let now = OffsetDateTime::now_utc(); let mut user_active = user.into_active_model(); if let Some(display_name) = cmd .display_name .as_deref() .map(str::trim) .filter(|name| !name.is_empty()) { if display_name.chars().count() > 64 { return Err(AuthBusinessError::BadRequest( "display_name must be at most 64 characters".to_string(), )); } let client = &self.accounts; let current = client.get_authority_user(uid).await.map_err(map_kenauth)?; client .update_authority_user( uid, None, Some(display_name.to_string()), false, None, current.version, ) .await .map_err(map_kenauth)?; } if let Some(locale) = cmd.locale { user_active.locale = Set(Some(locale)); } if let Some(language) = cmd.language { user_active.language = Set(Some(language)); } if let Some(timezone) = cmd.timezone { user_active.timezone = Set(Some(timezone)); } if let Some(dob) = cmd.date_of_birth { validate::validate_age(Some(dob), self.config().auth.min_age)?; } if let Some(visibility) = cmd.visibility { user_active.visibility = Set(visibility); } user_active.updated_at = Set(now); TransactionTrait::transaction(self.db().write(), |txn| { Box::pin(async move { user_active.update(txn).await?; let existing = user_profiles::Entity::find_by_id(uid).one(txn).await?; if let Some(row) = existing { let mut active = row.into_active_model(); if let Some(value) = cmd.real_name { active.real_name = Set(Some(value)); } if let Some(value) = cmd.bio { active.bio = Set(Some(value)); } if let Some(value) = cmd.pronouns { active.pronouns = Set(Some(value)); } if let Some(value) = cmd.website { active.website = Set(Some(value)); } if let Some(value) = cmd.location { active.location = Set(Some(value)); } if let Some(value) = cmd.company { active.company = Set(Some(value)); } if let Some(value) = cmd.date_of_birth { active.date_of_birth = Set(Some(value)); } if let Some(value) = cmd.show_email { active.show_email = Set(value); } if let Some(value) = cmd.show_location { active.show_location = Set(value); } if let Some(value) = cmd.show_real_name { active.show_real_name = Set(value); } if let Some(value) = cmd.show_activity { active.show_activity = Set(value); } active.updated_at = Set(now); active.update(txn).await?; } else { let row = user_profiles::ActiveModel { user_id: Set(uid), real_name: Set(cmd.real_name), bio: Set(cmd.bio), pronouns: Set(cmd.pronouns), website: Set(cmd.website), location: Set(cmd.location), company: Set(cmd.company), pronouns_privacy: Set(None), date_of_birth: Set(cmd.date_of_birth), banner_url: Set(None), banner_hash: Set(None), banner_color: Set(None), accent_color: Set(None), timezone_privacy_flags: Set(None), custom_status_emoji: Set(None), custom_status_text: Set(None), custom_status_expires_at: Set(None), show_email: Set(cmd.show_email.unwrap_or(false)), show_location: Set(cmd.show_location.unwrap_or(false)), show_real_name: Set(cmd.show_real_name.unwrap_or(false)), show_activity: Set(cmd.show_activity.unwrap_or(false)), privacy_agreed_at: Set(None), created_at: Set(now), updated_at: Set(now), }; row.insert(txn).await?; } Ok::<(), sea_orm::DbErr>(()) }) }) .await .map_err(AuthBusinessError::from) } pub async fn update_username( &self, uid: i64, new_username: &str, ) -> Result { let username = crate::share::account::validate::validate_username(new_username.trim())?; let db = self.db(); let local = user::Entity::find_by_id(uid).one(db.read()).await?; let client = &self.accounts; let current = client.get_authority_user(uid).await.map_err(map_kenauth)?; let updated = if current.username != username { client .update_authority_user( uid, Some(username.clone()), None, false, None, current.version, ) .await .map_err(map_kenauth)? } else { current }; let public = client.get_user_public(uid).await.map_err(map_kenauth)?; let display_name = updated .displayname .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| updated.username.clone()); let avatar_raw = local .as_ref() .map(|row| row.local_avatar_url.as_str()) .filter(|value| !value.is_empty()) .unwrap_or(public.avatar_url.as_str()); Ok(UserPublic { user_id: uid, username: updated.username, display_name, avatar_url: api::urls::resolve_user_avatar_url(uid, avatar_raw), }) } } fn map_kenauth(error: crate::errors::KenAuthError) -> AuthBusinessError { use crate::errors::KenAuthError; match error { KenAuthError::NotFound => AuthBusinessError::NotFound, KenAuthError::Unauthenticated => AuthBusinessError::Unauthorized, KenAuthError::Rejected(message) => { let lower = message.to_lowercase(); if lower.contains("conflict") || lower.contains("taken") || lower.contains("already") { AuthBusinessError::Conflict(message) } else { AuthBusinessError::BadRequest(message) } } KenAuthError::Unavailable(message) | KenAuthError::Config(message) | KenAuthError::Internal(message) => AuthBusinessError::Internal(message), } }