feat: init
This commit is contained in:
@@ -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(¶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,
|
||||
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};
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms.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()))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub use crate::service::util::{
|
||||
ensure_affected, merge_optional_text, parse_enum, required_text, sha256_hex,
|
||||
};
|
||||
Reference in New Issue
Block a user