feat: init

This commit is contained in:
zhenyi
2026-06-07 11:30:56 +08:00
commit 563381c1ca
361 changed files with 41327 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
use serde::{Deserialize, Serialize};
use crate::error::AppError;
use crate::models::common::Visibility;
use crate::models::users::User;
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;
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct UpdateUserAccountParams {
pub username: Option<String>,
pub display_name: Option<String>,
pub bio: Option<String>,
pub visibility: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct UploadUserAvatarParams {
pub data: Vec<u8>,
pub content_type: Option<String>,
pub file_name: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct UserAvatarResponse {
pub avatar_url: String,
pub storage_key: String,
}
impl UserService {
pub async fn user_account(&self, ctx: &Session) -> Result<User, AppError> {
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<User, AppError> {
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(&params.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,
params: UploadUserAvatarParams,
) -> Result<UserAvatarResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ext = avatar_extension(params.content_type.as_deref(), params.file_name.as_deref())?;
validate_avatar_size(params.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, params.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(UserAvatarResponse {
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 now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
for statement in [
"DELETE FROM user_personal_access_token WHERE user_id = $1",
"DELETE FROM user_security_log WHERE user_id = $1",
"DELETE FROM user_session WHERE user_id = $1",
"DELETE FROM user_device WHERE user_id = $1",
"DELETE FROM user_oauth WHERE user_id = $1",
"DELETE FROM user_ssh_key WHERE user_id = $1",
"DELETE FROM user_gpg_key WHERE user_id = $1",
"DELETE FROM user_2fa WHERE user_id = $1",
"DELETE FROM user_notify_setting WHERE user_id = $1",
"DELETE FROM user_appearance WHERE user_id = $1",
"DELETE FROM user_profile WHERE user_id = $1",
"DELETE FROM user_mail WHERE user_id = $1",
"DELETE FROM workspace_member WHERE user_id = $1",
"DELETE FROM repo_member WHERE user_id = $1",
] {
sqlx::query(statement)
.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', updated_at = $1 WHERE id = $2 AND deleted_at IS NULL",
)
.bind(now)
.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)?;
ctx.clear();
Ok(())
}
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<String>, current: Visibility) -> Result<Visibility, AppError> {
parse_enum(next.clone(), current, Visibility::Unknown, "visibility")
}
use crate::service::util::{avatar_extension, validate_avatar_size};
+107
View File
@@ -0,0 +1,107 @@
use serde::{Deserialize, Serialize};
use crate::error::AppError;
use crate::models::common::{ColorScheme, Density, FontSize, Theme};
use crate::models::users::UserAppearance;
use crate::service::UserService;
use crate::session::Session;
use super::util::{merge_optional_text, parse_enum};
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct UpdateUserAppearanceParams {
pub theme: Option<String>,
pub color_scheme: Option<String>,
pub density: Option<String>,
pub font_size: Option<String>,
pub editor_theme: Option<String>,
pub markdown_preview: Option<bool>,
pub reduced_motion: Option<bool>,
}
impl UserService {
pub async fn user_appearance(&self, ctx: &Session) -> Result<UserAppearance, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
self.ensure_user_appearance(user_uid).await
}
pub async fn user_update_appearance(
&self,
ctx: &Session,
params: UpdateUserAppearanceParams,
) -> Result<UserAppearance, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let current = self.ensure_user_appearance(user_uid).await?;
let theme = parse_enum(params.theme, current.theme, Theme::Unknown, "theme")?;
let color_scheme = parse_enum(
params.color_scheme,
current.color_scheme,
ColorScheme::Unknown,
"color_scheme",
)?;
let density = parse_enum(params.density, current.density, Density::Unknown, "density")?;
let font_size = parse_enum(
params.font_size,
current.font_size,
FontSize::Unknown,
"font_size",
)?;
sqlx::query_as::<_, UserAppearance>(
"UPDATE user_appearance SET theme = $1, color_scheme = $2, density = $3, font_size = $4, \
editor_theme = $5, markdown_preview = $6, reduced_motion = $7, updated_at = $8 \
WHERE user_id = $9 RETURNING user_id, theme, color_scheme, density, font_size, \
editor_theme, markdown_preview, reduced_motion, created_at, updated_at",
)
.bind(theme)
.bind(color_scheme)
.bind(density)
.bind(font_size)
.bind(merge_optional_text(params.editor_theme, current.editor_theme))
.bind(params.markdown_preview.unwrap_or(current.markdown_preview))
.bind(params.reduced_motion.unwrap_or(current.reduced_motion))
.bind(chrono::Utc::now())
.bind(user_uid)
.fetch_one(self.ctx.db.writer())
.await
.map_err(AppError::Database)
}
async fn ensure_user_appearance(
&self,
user_uid: uuid::Uuid,
) -> Result<UserAppearance, AppError> {
if let Some(appearance) = self.find_user_appearance(user_uid).await? {
return Ok(appearance);
}
let now = chrono::Utc::now();
sqlx::query(
"INSERT INTO user_appearance (user_id, theme, color_scheme, density, font_size, \
markdown_preview, reduced_motion, created_at, updated_at) \
VALUES ($1, 'system', 'system', 'comfortable', 'medium', true, false, $2, $2) ON CONFLICT (user_id) DO NOTHING",
)
.bind(user_uid)
.bind(now)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
self.find_user_appearance(user_uid)
.await?
.ok_or(AppError::UserNotFound)
}
async fn find_user_appearance(
&self,
user_uid: uuid::Uuid,
) -> Result<Option<UserAppearance>, AppError> {
sqlx::query_as::<_, UserAppearance>(
"SELECT user_id, theme, color_scheme, density, font_size, editor_theme, \
markdown_preview, reduced_motion, created_at, updated_at \
FROM user_appearance WHERE user_id = $1",
)
.bind(user_uid)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
}
+232
View File
@@ -0,0 +1,232 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AppError;
use crate::models::common::KeyType;
use crate::models::users::{UserGpgKey, UserSshKey};
use crate::service::UserService;
use crate::session::Session;
use super::util::{ensure_affected, required_text};
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct AddSshKeyParams {
pub title: String,
pub public_key: String,
pub key_type: String,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct AddGpgKeyParams {
pub public_key: String,
pub key_id: String,
pub primary_email: Option<String>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl UserService {
pub async fn user_ssh_keys(&self, ctx: &Session) -> Result<Vec<UserSshKey>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
sqlx::query_as::<_, UserSshKey>(
"SELECT id, user_id, title, public_key, fingerprint_sha256, key_type, last_used_at, \
expires_at, revoked_at, created_at, updated_at FROM user_ssh_key \
WHERE user_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC",
)
.bind(user_uid)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
pub async fn user_add_ssh_key(
&self,
ctx: &Session,
params: AddSshKeyParams,
) -> Result<UserSshKey, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let title = required_text(params.title, "title")?;
let public_key = required_text(params.public_key, "public_key")?;
let key_type = parse_key_type(&params.key_type)?;
let fingerprint = ssh_fingerprint(&public_key, key_type)?;
let now = chrono::Utc::now();
let existing = sqlx::query(
"SELECT id FROM user_ssh_key WHERE fingerprint_sha256 = $1 AND user_id = $2 AND revoked_at IS NULL LIMIT 1",
)
.bind(&fingerprint)
.bind(user_uid)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
if existing.is_some() {
return Err(AppError::Conflict("SSH key already exists".into()));
}
sqlx::query_as::<_, UserSshKey>(
"INSERT INTO user_ssh_key (id, user_id, title, public_key, fingerprint_sha256, key_type, \
expires_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \
RETURNING id, user_id, title, public_key, fingerprint_sha256, key_type, last_used_at, \
expires_at, revoked_at, created_at, updated_at",
)
.bind(Uuid::now_v7())
.bind(user_uid)
.bind(title)
.bind(&public_key)
.bind(fingerprint)
.bind(key_type)
.bind(params.expires_at)
.bind(now)
.fetch_one(self.ctx.db.writer())
.await
.map_err(AppError::Database)
}
pub async fn user_delete_ssh_key(&self, ctx: &Session, key_uid: Uuid) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let result = sqlx::query(
"UPDATE user_ssh_key SET revoked_at = $1, updated_at = $1 \
WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL",
)
.bind(chrono::Utc::now())
.bind(key_uid)
.bind(user_uid)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
ensure_affected(result.rows_affected(), "key not found")
}
pub async fn user_gpg_keys(&self, ctx: &Session) -> Result<Vec<UserGpgKey>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
sqlx::query_as::<_, UserGpgKey>(
"SELECT id, user_id, key_id, public_key, fingerprint, primary_email, expires_at, \
verified_at, revoked_at, created_at, updated_at FROM user_gpg_key \
WHERE user_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC",
)
.bind(user_uid)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
pub async fn user_add_gpg_key(
&self,
ctx: &Session,
params: AddGpgKeyParams,
) -> Result<UserGpgKey, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let public_key = required_text(params.public_key, "public_key")?;
let key_id = required_text(params.key_id, "key_id")?;
let primary_email = params
.primary_email
.map(|v| v.trim().to_lowercase())
.filter(|v| !v.is_empty());
let fingerprint = gpg_fingerprint(&public_key)?;
let now = chrono::Utc::now();
let existing = sqlx::query(
"SELECT id FROM user_gpg_key WHERE fingerprint = $1 AND user_id = $2 AND revoked_at IS NULL LIMIT 1",
)
.bind(&fingerprint)
.bind(user_uid)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
if existing.is_some() {
return Err(AppError::Conflict("GPG key already exists".into()));
}
sqlx::query_as::<_, UserGpgKey>(
"INSERT INTO user_gpg_key (id, user_id, key_id, public_key, fingerprint, primary_email, \
expires_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \
RETURNING id, user_id, key_id, public_key, fingerprint, primary_email, expires_at, \
verified_at, revoked_at, created_at, updated_at",
)
.bind(Uuid::now_v7())
.bind(user_uid)
.bind(key_id)
.bind(&public_key)
.bind(&fingerprint)
.bind(primary_email)
.bind(params.expires_at)
.bind(now)
.fetch_one(self.ctx.db.writer())
.await
.map_err(AppError::Database)
}
pub async fn user_delete_gpg_key(&self, ctx: &Session, key_uid: Uuid) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let result = sqlx::query(
"UPDATE user_gpg_key SET revoked_at = $1, updated_at = $1 \
WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL",
)
.bind(chrono::Utc::now())
.bind(key_uid)
.bind(user_uid)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
ensure_affected(result.rows_affected(), "key not found")
}
}
fn parse_key_type(value: &str) -> Result<KeyType, AppError> {
use crate::models::common::KeyType;
let key_type = value
.trim()
.parse::<KeyType>()
.map_err(|_| AppError::BadRequest("invalid key_type".into()))?;
if key_type == KeyType::Unknown {
return Err(AppError::BadRequest("invalid key_type".into()));
}
Ok(key_type)
}
fn ssh_fingerprint(public_key: &str, expected_type: KeyType) -> Result<String, AppError> {
use base64::Engine;
let parts: Vec<&str> = public_key.split_whitespace().collect();
if parts.len() < 2 {
return Err(AppError::BadRequest("invalid SSH public key format".into()));
}
let actual_type = ssh_key_type(parts[0])?;
if actual_type != expected_type {
return Err(AppError::BadRequest(
"key_type does not match SSH public key".into(),
));
}
let decoded = base64::engine::general_purpose::STANDARD
.decode(parts[1])
.map_err(|_| AppError::BadRequest("invalid SSH public key data".into()))?;
if decoded.is_empty() {
return Err(AppError::BadRequest("invalid SSH public key data".into()));
}
Ok(super::util::sha256_hex(&decoded))
}
fn ssh_key_type(value: &str) -> Result<KeyType, AppError> {
match value {
"ssh-rsa" => Ok(KeyType::Rsa),
"ssh-ed25519" => Ok(KeyType::Ed25519),
v if v.starts_with("ecdsa-sha2-") => Ok(KeyType::Ecdsa),
"ssh-dss" => Ok(KeyType::Dsa),
_ => Err(AppError::BadRequest("unsupported SSH key type".into())),
}
}
fn gpg_fingerprint(public_key: &str) -> Result<String, AppError> {
let key = public_key.trim();
if !key.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----")
|| !key.contains("-----END PGP PUBLIC KEY BLOCK-----")
{
return Err(AppError::BadRequest("invalid GPG public key format".into()));
}
Ok(super::util::sha256_hex(key.as_bytes()))
}
+7
View File
@@ -0,0 +1,7 @@
pub mod account;
pub mod appearance;
pub mod keys;
pub mod notify;
pub mod profile;
pub mod security;
pub mod util;
+122
View File
@@ -0,0 +1,122 @@
use serde::{Deserialize, Serialize};
use crate::error::AppError;
use crate::models::common::DigestFrequency;
use crate::models::users::UserNotifySetting;
use crate::service::UserService;
use crate::session::Session;
use super::util::parse_enum;
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct UpdateUserNotifySettingParams {
pub email_notifications: Option<bool>,
pub web_notifications: Option<bool>,
pub mention_notifications: Option<bool>,
pub review_notifications: Option<bool>,
pub security_notifications: Option<bool>,
pub marketing_emails: Option<bool>,
pub digest_frequency: Option<String>,
}
impl UserService {
pub async fn user_notify_setting(&self, ctx: &Session) -> Result<UserNotifySetting, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
self.ensure_user_notify_setting(user_uid).await
}
pub async fn user_update_notify_setting(
&self,
ctx: &Session,
params: UpdateUserNotifySettingParams,
) -> Result<UserNotifySetting, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let current = self.ensure_user_notify_setting(user_uid).await?;
let digest_frequency = parse_enum(
params.digest_frequency,
current.digest_frequency,
DigestFrequency::Unknown,
"digest_frequency",
)?;
sqlx::query_as::<_, UserNotifySetting>(
"UPDATE user_notify_setting SET email_notifications = $1, web_notifications = $2, \
mention_notifications = $3, review_notifications = $4, security_notifications = $5, \
marketing_emails = $6, digest_frequency = $7, updated_at = $8 WHERE user_id = $9 \
RETURNING user_id, email_notifications, web_notifications, mention_notifications, \
review_notifications, security_notifications, marketing_emails, digest_frequency, \
created_at, updated_at",
)
.bind(
params
.email_notifications
.unwrap_or(current.email_notifications),
)
.bind(
params
.web_notifications
.unwrap_or(current.web_notifications),
)
.bind(
params
.mention_notifications
.unwrap_or(current.mention_notifications),
)
.bind(
params
.review_notifications
.unwrap_or(current.review_notifications),
)
.bind(
params
.security_notifications
.unwrap_or(current.security_notifications),
)
.bind(params.marketing_emails.unwrap_or(current.marketing_emails))
.bind(digest_frequency)
.bind(chrono::Utc::now())
.bind(user_uid)
.fetch_one(self.ctx.db.writer())
.await
.map_err(AppError::Database)
}
async fn ensure_user_notify_setting(
&self,
user_uid: uuid::Uuid,
) -> Result<UserNotifySetting, AppError> {
if let Some(setting) = self.find_user_notify_setting(user_uid).await? {
return Ok(setting);
}
let now = chrono::Utc::now();
sqlx::query(
"INSERT INTO user_notify_setting (user_id, email_notifications, web_notifications, \
mention_notifications, review_notifications, security_notifications, marketing_emails, \
digest_frequency, created_at, updated_at) \
VALUES ($1, true, true, true, true, true, false, 'realtime', $2, $2) ON CONFLICT (user_id) DO NOTHING",
)
.bind(user_uid)
.bind(now)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
self.find_user_notify_setting(user_uid)
.await?
.ok_or(AppError::UserNotFound)
}
async fn find_user_notify_setting(
&self,
user_uid: uuid::Uuid,
) -> Result<Option<UserNotifySetting>, AppError> {
sqlx::query_as::<_, UserNotifySetting>(
"SELECT user_id, email_notifications, web_notifications, mention_notifications, \
review_notifications, security_notifications, marketing_emails, digest_frequency, \
created_at, updated_at FROM user_notify_setting WHERE user_id = $1",
)
.bind(user_uid)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
}
+90
View File
@@ -0,0 +1,90 @@
use serde::{Deserialize, Serialize};
use crate::error::AppError;
use crate::models::users::UserProfile;
use crate::service::UserService;
use crate::session::Session;
use super::util::merge_optional_text;
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct UpdateUserProfileParams {
pub full_name: Option<String>,
pub company: Option<String>,
pub location: Option<String>,
pub website_url: Option<String>,
pub twitter_username: Option<String>,
pub timezone: Option<String>,
pub language: Option<String>,
pub profile_readme: Option<String>,
}
impl UserService {
pub async fn user_profile(&self, ctx: &Session) -> Result<UserProfile, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
self.ensure_user_profile(user_uid).await
}
pub async fn user_update_profile(
&self,
ctx: &Session,
params: UpdateUserProfileParams,
) -> Result<UserProfile, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let current = self.ensure_user_profile(user_uid).await?;
let now = chrono::Utc::now();
sqlx::query_as::<_, UserProfile>(
"UPDATE user_profile SET full_name = $1, company = $2, location = $3, website_url = $4, \
twitter_username = $5, timezone = $6, language = $7, profile_readme = $8, updated_at = $9 \
WHERE user_id = $10 RETURNING user_id, full_name, company, location, website_url, \
twitter_username, timezone, language, profile_readme, created_at, updated_at",
)
.bind(merge_optional_text(params.full_name, current.full_name))
.bind(merge_optional_text(params.company, current.company))
.bind(merge_optional_text(params.location, current.location))
.bind(merge_optional_text(params.website_url, current.website_url))
.bind(merge_optional_text(params.twitter_username, current.twitter_username))
.bind(merge_optional_text(params.timezone, current.timezone))
.bind(merge_optional_text(params.language, current.language))
.bind(merge_optional_text(params.profile_readme, current.profile_readme))
.bind(now)
.bind(user_uid)
.fetch_one(self.ctx.db.writer())
.await
.map_err(AppError::Database)
}
async fn ensure_user_profile(&self, user_uid: uuid::Uuid) -> Result<UserProfile, AppError> {
if let Some(profile) = self.find_user_profile(user_uid).await? {
return Ok(profile);
}
let now = chrono::Utc::now();
sqlx::query(
"INSERT INTO user_profile (user_id, created_at, updated_at) VALUES ($1, $2, $2) ON CONFLICT (user_id) DO NOTHING",
)
.bind(user_uid)
.bind(now)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
self.find_user_profile(user_uid)
.await?
.ok_or(AppError::UserNotFound)
}
async fn find_user_profile(
&self,
user_uid: uuid::Uuid,
) -> Result<Option<UserProfile>, AppError> {
sqlx::query_as::<_, UserProfile>(
"SELECT user_id, full_name, company, location, website_url, twitter_username, \
timezone, language, profile_readme, created_at, updated_at \
FROM user_profile WHERE user_id = $1",
)
.bind(user_uid)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
}
+331
View File
@@ -0,0 +1,331 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AppError;
use crate::models::common::{EventType, JsonValue, Provider, Scope};
use crate::models::users::{UserDevice, UserSecurityLog};
use crate::service::UserService;
use crate::session::Session;
use super::util::ensure_affected;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserSessionInfo {
pub id: Uuid,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub last_active_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub revoked_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserOAuthInfo {
pub id: Uuid,
pub provider: Provider,
pub provider_user_id: String,
pub provider_username: Option<String>,
pub provider_email: Option<String>,
pub token_expires_at: Option<DateTime<Utc>>,
pub linked_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserPersonalAccessTokenInfo {
pub id: Uuid,
pub name: String,
pub scopes: Vec<Scope>,
pub last_used_at: Option<DateTime<Utc>>,
pub expires_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
struct UserSessionRow {
id: Uuid,
ip_address: Option<String>,
user_agent: Option<String>,
last_active_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
revoked_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
struct UserOAuthRow {
id: Uuid,
provider: Provider,
provider_user_id: String,
provider_username: Option<String>,
provider_email: Option<String>,
token_expires_at: Option<DateTime<Utc>>,
linked_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
struct UserPersonalAccessTokenRow {
id: Uuid,
name: String,
scopes: Vec<Scope>,
last_used_at: Option<DateTime<Utc>>,
expires_at: Option<DateTime<Utc>>,
revoked_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl UserService {
pub async fn user_devices(&self, ctx: &Session) -> Result<Vec<UserDevice>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
sqlx::query_as::<_, UserDevice>(
"SELECT id, user_id, device_name, device_type, fingerprint, ip_address, user_agent, \
trusted, last_seen_at, created_at, updated_at FROM user_device \
WHERE user_id = $1 ORDER BY last_seen_at DESC NULLS LAST, created_at DESC",
)
.bind(user_uid)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
pub async fn user_delete_device(
&self,
ctx: &Session,
device_uid: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let result = sqlx::query("DELETE FROM user_device WHERE id = $1 AND user_id = $2")
.bind(device_uid)
.bind(user_uid)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
ensure_affected(result.rows_affected(), "device not found")
}
pub async fn user_sessions(
&self,
ctx: &Session,
limit: i64,
offset: i64,
) -> Result<Vec<UserSessionInfo>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let limit = limit.clamp(1, 100);
let offset = offset.max(0);
let rows = sqlx::query_as::<_, UserSessionRow>(
"SELECT id, ip_address, user_agent, last_active_at, expires_at, revoked_at, created_at \
FROM user_session WHERE user_id = $1 ORDER BY last_active_at DESC LIMIT $2 OFFSET $3",
)
.bind(user_uid)
.bind(limit)
.bind(offset)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
Ok(rows.into_iter().map(Into::into).collect())
}
pub async fn user_revoke_session(
&self,
ctx: &Session,
session_uid: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let result = sqlx::query(
"UPDATE user_session SET revoked_at = $1 \
WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL",
)
.bind(chrono::Utc::now())
.bind(session_uid)
.bind(user_uid)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
ensure_affected(result.rows_affected(), "session not found")
}
pub async fn user_oauth_accounts(&self, ctx: &Session) -> Result<Vec<UserOAuthInfo>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let rows = sqlx::query_as::<_, UserOAuthRow>(
"SELECT id, provider, provider_user_id, provider_username, provider_email, \
token_expires_at, linked_at, last_used_at FROM user_oauth \
WHERE user_id = $1 ORDER BY linked_at DESC",
)
.bind(user_uid)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
Ok(rows.into_iter().map(Into::into).collect())
}
pub async fn user_unlink_oauth(&self, ctx: &Session, oauth_uid: Uuid) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let has_password: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_password WHERE user_id = $1)")
.bind(user_uid)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
let oauth_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM user_oauth WHERE user_id = $1")
.bind(user_uid)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
if !has_password && oauth_count <= 1 {
return Err(AppError::BadRequest(
"cannot unlink the last login method; please set a password first".into(),
));
}
let result = sqlx::query("DELETE FROM user_oauth WHERE id = $1 AND user_id = $2")
.bind(oauth_uid)
.bind(user_uid)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
ensure_affected(result.rows_affected(), "oauth account not found")
}
pub async fn user_security_logs(
&self,
ctx: &Session,
limit: i64,
offset: i64,
) -> Result<Vec<UserSecurityLog>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let limit = limit.clamp(1, 100);
let offset = offset.max(0);
sqlx::query_as::<_, UserSecurityLog>(
"SELECT id, user_id, event_type, description, ip_address, user_agent, metadata, created_at \
FROM user_security_log WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
)
.bind(user_uid)
.bind(limit)
.bind(offset)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
pub async fn user_log_security_event(
&self,
user_uid: Uuid,
event_type: EventType,
description: Option<String>,
ip_address: Option<String>,
user_agent: Option<String>,
metadata: Option<JsonValue>,
) -> Result<(), AppError> {
sqlx::query(
"INSERT INTO user_security_log (id, user_id, event_type, description, ip_address, user_agent, metadata, created_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
)
.bind(Uuid::now_v7())
.bind(user_uid)
.bind(event_type)
.bind(description)
.bind(ip_address)
.bind(user_agent)
.bind(metadata)
.bind(chrono::Utc::now())
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
Ok(())
}
pub async fn user_personal_access_tokens(
&self,
ctx: &Session,
limit: i64,
offset: i64,
) -> Result<Vec<UserPersonalAccessTokenInfo>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let limit = limit.clamp(1, 100);
let offset = offset.max(0);
let rows = sqlx::query_as::<_, UserPersonalAccessTokenRow>(
"SELECT id, name, scopes, last_used_at, expires_at, revoked_at, created_at, updated_at \
FROM user_personal_access_token WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
)
.bind(user_uid)
.bind(limit)
.bind(offset)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
Ok(rows.into_iter().map(Into::into).collect())
}
pub async fn user_revoke_personal_access_token(
&self,
ctx: &Session,
token_uid: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let result = sqlx::query(
"UPDATE user_personal_access_token SET revoked_at = $1, updated_at = $1 \
WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL",
)
.bind(chrono::Utc::now())
.bind(token_uid)
.bind(user_uid)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
ensure_affected(result.rows_affected(), "token not found")
}
}
impl From<UserSessionRow> for UserSessionInfo {
fn from(row: UserSessionRow) -> Self {
Self {
id: row.id,
ip_address: row.ip_address,
user_agent: row.user_agent,
last_active_at: row.last_active_at,
expires_at: row.expires_at,
revoked_at: row.revoked_at,
created_at: row.created_at,
}
}
}
impl From<UserOAuthRow> for UserOAuthInfo {
fn from(row: UserOAuthRow) -> Self {
Self {
id: row.id,
provider: row.provider,
provider_user_id: row.provider_user_id,
provider_username: row.provider_username,
provider_email: row.provider_email,
token_expires_at: row.token_expires_at,
linked_at: row.linked_at,
last_used_at: row.last_used_at,
}
}
}
impl From<UserPersonalAccessTokenRow> for UserPersonalAccessTokenInfo {
fn from(row: UserPersonalAccessTokenRow) -> Self {
Self {
id: row.id,
name: row.name,
scopes: row.scopes,
last_used_at: row.last_used_at,
expires_at: row.expires_at,
revoked_at: row.revoked_at,
created_at: row.created_at,
updated_at: row.updated_at,
}
}
}
+3
View File
@@ -0,0 +1,3 @@
pub use crate::service::util::{
ensure_affected, merge_optional_text, parse_enum, required_text, sha256_hex,
};