use serde::{Deserialize, Serialize}; use crate::error::AppError; use crate::models::common::Visibility; use crate::models::users::User; use crate::pb::email::{EmailAddress, SendEmailRequest}; use crate::service::UserService; use crate::session::Session; use super::util::{merge_optional_text, parse_enum}; use crate::service::util::{extract_storage_key_from_url, sha256_hex}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct UpdateUserAccountParams { pub username: Option, pub display_name: Option, pub bio: Option, pub visibility: Option, } impl UserService { const RESTORE_TOKEN_VALIDITY_DAYS: i64 = 30; pub async fn user_account(&self, ctx: &Session) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; crate::models::users::User::find_by_id(self.ctx.db.reader(), user_uid) .await .map_err(AppError::Database)? .ok_or(AppError::UserNotFound) } pub async fn user_update_account( &self, ctx: &Session, params: UpdateUserAccountParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let current = crate::models::users::User::find_by_id(self.ctx.db.reader(), user_uid) .await .map_err(AppError::Database)? .ok_or(AppError::UserNotFound)?; let username = params .username .map(|v| v.trim().to_string()) .unwrap_or(current.username); if username.is_empty() { return Err(AppError::BadRequest("username is required".into())); } self.ensure_username_available(&username, user_uid).await?; let visibility = parse_visibility(¶ms.visibility, current.visibility)?; let display_name = merge_optional_text(params.display_name, current.display_name); let bio = merge_optional_text(params.bio, current.bio); sqlx::query_as::<_, User>( "UPDATE \"user\" SET username = $1, display_name = $2, bio = $3, visibility = $4, \ updated_at = $5 WHERE id = $6 AND deleted_at IS NULL \ RETURNING id, username, display_name, avatar_url, bio, status, role, visibility, \ is_active, is_bot, last_login_at, created_at, updated_at, deleted_at", ) .bind(&username) .bind(&display_name) .bind(&bio) .bind(visibility) .bind(chrono::Utc::now()) .bind(user_uid) .fetch_optional(self.ctx.db.writer()) .await .map_err(AppError::Database)? .ok_or(AppError::UserNotFound) } pub async fn user_upload_avatar( &self, ctx: &Session, data: Vec, content_type: Option, file_name: Option, ) -> Result<(String, String), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let ext = avatar_extension(content_type.as_deref(), file_name.as_deref())?; validate_avatar_size(data.len(), self.ctx.config.s3_max_upload_size()?)?; let current = crate::models::users::User::find_by_id(self.ctx.db.reader(), user_uid) .await .map_err(AppError::Database)? .ok_or(AppError::UserNotFound)?; let old_avatar_url = current.avatar_url.clone(); let storage_key = format!("users/{}/avatar/{}.{}", user_uid, uuid::Uuid::now_v7(), ext); self.ctx.storage.put(&storage_key, data).await?; let avatar_url = self.ctx.storage.public_url(&storage_key).ok_or_else(|| { AppError::Config("APP_S3_PUBLIC_URL is required for avatar upload".into()) })?; let result = sqlx::query( "UPDATE \"user\" SET avatar_url = $1, updated_at = $2 \ WHERE id = $3 AND deleted_at IS NULL", ) .bind(&avatar_url) .bind(chrono::Utc::now()) .bind(user_uid) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; if result.rows_affected() == 0 { let _ = self.ctx.storage.delete(&storage_key).await; return Err(AppError::UserNotFound); } if let Some(old_url) = old_avatar_url && let Some(old_key) = extract_storage_key_from_url(&old_url) { let _ = self.ctx.storage.delete(&old_key).await; } Ok((avatar_url, storage_key)) } pub async fn user_delete_account(&self, ctx: &Session) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let owned_workspace_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM workspace WHERE owner_id = $1 AND deleted_at IS NULL", ) .bind(user_uid) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database)?; if owned_workspace_count > 0 { return Err(AppError::BadRequest( "transfer or delete owned workspaces before deleting the account".into(), )); } let owned_repo_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM repo WHERE owner_id = $1 AND deleted_at IS NULL", ) .bind(user_uid) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database)?; if owned_repo_count > 0 { return Err(AppError::BadRequest( "transfer or delete owned repos before deleting the account".into(), )); } let has_verified_email: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM user_mail WHERE user_id = $1 AND is_verified = true AND deleted_at IS NULL)", ) .bind(user_uid) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database)?; if !has_verified_email { return Err(AppError::BadRequest( "please add and verify an email address before deleting your account".into(), )); } let primary_email: Option = sqlx::query_scalar( "SELECT email FROM user_mail WHERE user_id = $1 AND is_verified = true AND is_primary = true AND deleted_at IS NULL LIMIT 1", ) .bind(user_uid) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)?; let fallback_email: Option = sqlx::query_scalar( "SELECT email FROM user_mail WHERE user_id = $1 AND is_verified = true AND deleted_at IS NULL ORDER BY created_at LIMIT 1", ) .bind(user_uid) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)?; let email = primary_email.or(fallback_email); let now = chrono::Utc::now(); let restore_token = uuid::Uuid::now_v7().to_string(); let token_hash = sha256_hex(restore_token.as_bytes()); let expires_at = now + chrono::Duration::days(Self::RESTORE_TOKEN_VALIDITY_DAYS); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; for statement in [ "UPDATE user_personal_access_token SET revoked_at = $1 WHERE user_id = $2 AND revoked_at IS NULL", "UPDATE user_session SET revoked_at = $1 WHERE user_id = $2 AND revoked_at IS NULL", "UPDATE user_ssh_key SET revoked_at = $1 WHERE user_id = $2 AND revoked_at IS NULL", "UPDATE user_gpg_key SET revoked_at = $1 WHERE user_id = $2 AND revoked_at IS NULL", "UPDATE workspace_member SET status = 'deleted' WHERE user_id = $2 AND status != 'deleted'", "UPDATE repo_member SET status = 'deleted' WHERE user_id = $2 AND status != 'deleted'", "UPDATE user_2fa SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", "UPDATE user_activity SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", "UPDATE user_appearance SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", "UPDATE user_block SET deleted_at = $1 WHERE (user_id = $2 OR blocked_user_id = $2) AND deleted_at IS NULL", "UPDATE user_device SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", "UPDATE user_follow SET deleted_at = $1 WHERE (user_id = $2 OR following_user_id = $2) AND deleted_at IS NULL", "UPDATE user_mail SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", "UPDATE user_notify_setting SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", "UPDATE user_oauth SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", "UPDATE user_password SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", "UPDATE user_password_reset SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", "UPDATE user_presence SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", "UPDATE user_profile SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", "UPDATE user_security_log SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", ] { sqlx::query(statement) .bind(now) .bind(user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; } let result = sqlx::query( "UPDATE \"user\" SET deleted_at = $1, is_active = false, status = 'deleted', \ restore_token_hash = $2, restore_token_expires_at = $3, updated_at = $1 \ WHERE id = $4 AND deleted_at IS NULL", ) .bind(now) .bind(&token_hash) .bind(expires_at) .bind(user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; if result.rows_affected() == 0 { return Err(AppError::UserNotFound); } txn.commit().await.map_err(|_| AppError::TxnError)?; if let Some(email) = email { let _ = self.send_restore_email(&email, &restore_token).await; } ctx.clear(); Ok(()) } pub async fn user_restore(&self, token: &str) -> Result<(), AppError> { let token_hash = sha256_hex(token.as_bytes()); let user_id: Option = sqlx::query_scalar( "SELECT id FROM \"user\" WHERE restore_token_hash = $1 \ AND deleted_at IS NOT NULL \ AND restore_token_expires_at > NOW()", ) .bind(&token_hash) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)?; let user_uid = user_id.ok_or(AppError::NotFound("invalid or expired restore link".into()))?; let now = chrono::Utc::now(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; for statement in [ "UPDATE user_personal_access_token SET revoked_at = NULL WHERE user_id = $1 AND revoked_at IS NOT NULL", "UPDATE user_session SET revoked_at = NULL WHERE user_id = $1 AND revoked_at IS NOT NULL", "UPDATE user_ssh_key SET revoked_at = NULL WHERE user_id = $1 AND revoked_at IS NOT NULL", "UPDATE user_gpg_key SET revoked_at = NULL WHERE user_id = $1 AND revoked_at IS NOT NULL", "UPDATE workspace_member SET status = 'active' WHERE user_id = $1 AND status = 'deleted'", "UPDATE repo_member SET status = 'active' WHERE user_id = $1 AND status = 'deleted'", "UPDATE user_2fa SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", "UPDATE user_activity SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", "UPDATE user_appearance SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", "UPDATE user_block SET deleted_at = NULL WHERE (user_id = $1 OR blocked_user_id = $1) AND deleted_at IS NOT NULL", "UPDATE user_device SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", "UPDATE user_follow SET deleted_at = NULL WHERE (user_id = $1 OR following_user_id = $1) AND deleted_at IS NOT NULL", "UPDATE user_mail SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", "UPDATE user_notify_setting SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", "UPDATE user_oauth SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", "UPDATE user_password SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", "UPDATE user_password_reset SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", "UPDATE user_presence SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", "UPDATE user_profile SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", "UPDATE user_security_log SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", ] { sqlx::query(statement) .bind(user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; } let result = sqlx::query( "UPDATE \"user\" SET deleted_at = NULL, is_active = true, status = 'active', \ restore_token_hash = NULL, restore_token_expires_at = NULL, updated_at = $1 \ WHERE id = $2 AND deleted_at IS NOT NULL", ) .bind(now) .bind(user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; if result.rows_affected() == 0 { return Err(AppError::NotFound("user not found".into())); } txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(()) } async fn send_restore_email(&self, email: &str, token: &str) -> Result<(), AppError> { let app_url = self .ctx .config .get_env::("APP_URL") .ok() .flatten() .unwrap_or_else(|| "http://localhost:8000".to_string()); let base = app_url.trim_end_matches('/'); let restore_url = format!("{}/account/restore?token={}", base, token); let mut mail = self .ctx .registry .get_email_client() .ok_or(AppError::Config("mail service not available".into()))?; mail.send_email(tonic::Request::new(SendEmailRequest { to: vec![EmailAddress { email: email.to_string(), name: String::new(), }], subject: "Account Deletion - Restore Link".into(), text_body: format!( "Your account has been marked for deletion.\n\n\ If you did not request this, you can restore your account within 30 days \ by visiting the following link:\n\n\ {}\n\n\ This link expires in 30 days. After that, your data will be retained but \ the restore link will no longer work.", restore_url, ), ..Default::default() })) .await .map(|_| ()) .map_err(|e| { tracing::warn!(?e, "failed to send restore email"); AppError::InternalServerError(e.to_string()) }) } async fn ensure_username_available( &self, username: &str, user_uid: uuid::Uuid, ) -> Result<(), AppError> { let exists = sqlx::query( "SELECT id FROM \"user\" WHERE lower(username) = lower($1) \ AND id <> $2 AND deleted_at IS NULL LIMIT 1", ) .bind(username) .bind(user_uid) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)?; if exists.is_some() { return Err(AppError::AccountAlreadyExists); } Ok(()) } } fn parse_visibility(next: &Option, current: Visibility) -> Result { parse_enum(next.clone(), current, Visibility::Unknown, "visibility") } use crate::service::util::{avatar_extension, validate_avatar_size};