feat(service): expand service layer with new domain operations

- Add IM service modules: audit, channel roles, custom emojis, forum
  tags, integrations, invitations, repo links, slash commands, stages,
  voice, webhooks
- Add PR service modules: review requests, templates
- Add repo service modules: contributors, release assets, git extras
  (archive, branch rename, commit extras, diff/merge, tag, tree)
- Add user service: social (follow/block)
- Add internal auth service
- Update existing service modules with expanded functionality
- Remove deleted IM modules: articles, delivery trace, drafts,
  follows, messages, polls, presence, reactions, threads
This commit is contained in:
zhenyi
2026-06-10 18:49:32 +08:00
parent cec6dce955
commit 420dedbc1e
100 changed files with 3797 additions and 3839 deletions
+79
View File
@@ -0,0 +1,79 @@
use argon2::{
Argon2, PasswordHasher,
password_hash::{PasswordHash, PasswordVerifier, SaltString},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sqlx::Row;
use crate::error::AppError;
use crate::service::AuthService;
use crate::session::Session;
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct ChangePasswordParams {
pub current_password: String,
pub new_password: String,
}
impl AuthService {
pub async fn auth_change_password(
&self,
session: &Session,
params: ChangePasswordParams,
) -> Result<(), AppError> {
let user_uid = session.user().ok_or(AppError::Unauthorized)?;
let current_password = self
.auth_rsa_decode(session, params.current_password)
.await?;
let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1")
.bind(user_uid)
.fetch_optional(self.ctx.db.writer())
.await
.map_err(AppError::Database)?
.ok_or(AppError::UserNotFound)?;
let hash: String = row.try_get("password_hash").map_err(AppError::Database)?;
let password_hash = PasswordHash::new(&hash).map_err(|_| AppError::InvalidPassword)?;
if Argon2::default()
.verify_password(current_password.as_bytes(), &password_hash)
.is_err()
{
return Err(AppError::InvalidPassword);
}
let new_password = self.auth_rsa_decode(session, params.new_password).await?;
crate::service::util::validate_password_strength(&new_password)?;
let salt = SaltString::generate(&mut rand::thread_rng());
let new_hash = Argon2::default()
.hash_password(new_password.as_bytes(), &salt)
.map_err(|e| AppError::PasswordHashError(e.to_string()))?
.to_string();
let now = Utc::now();
let result = sqlx::query(
"UPDATE user_password SET password_hash = $1, password_updated_at = $2, updated_at = $2 \
WHERE user_id = $3",
)
.bind(&new_hash)
.bind(now)
.bind(user_uid)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
if result.rows_affected() == 0 {
return Err(AppError::UserNotFound);
}
session.remove(Self::RSA_PRIVATE_KEY);
session.remove(Self::RSA_PUBLIC_KEY);
tracing::info!(user_uid = %user_uid, "Password changed successfully");
Ok(())
}
}
+24 -2
View File
@@ -35,6 +35,7 @@ struct PendingEmailChange {
impl AuthService {
const EMAIL_CHANGE_PREFIX: &'static str = "auth:email_change:";
const EMAIL_CHANGE_TTL_SECS: u64 = 60 * 60;
const EMAIL_CHANGE_COOLDOWN_SECS: u64 = 60;
pub async fn auth_get_email(&self, ctx: &Session) -> Result<EmailResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
@@ -63,11 +64,20 @@ impl AuthService {
if new_email.is_empty() {
return Err(AppError::BadRequest("email is required".into()));
}
// Rate limiting: check cooldown
let cooldown_key = format!("{}cooldown:{}", Self::EMAIL_CHANGE_PREFIX, user_uid);
if self.ctx.cache.exists(&cooldown_key).await {
return Err(AppError::BadRequest(
"email change request was sent recently; please try again later".into(),
));
}
let password = self.auth_rsa_decode(ctx, params.password).await?;
let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1")
.bind(user_uid)
.fetch_optional(self.ctx.db.reader())
.fetch_optional(self.ctx.db.writer())
.await
.map_err(AppError::Database)?
.ok_or(AppError::UserNotFound)?;
@@ -103,6 +113,7 @@ impl AuthService {
},
Some(Duration::from_secs(Self::EMAIL_CHANGE_TTL_SECS)),
)
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let domain = self.ctx.config.main_domain()?;
@@ -133,6 +144,16 @@ impl AuthService {
AppError::InternalServerError(e.to_string())
})?;
// Set cooldown after successful email send
let cooldown_key = format!("{}cooldown:{}", Self::EMAIL_CHANGE_PREFIX, user_uid);
if let Err(e) = self.ctx.cache.set(
&cooldown_key,
&true,
Some(Duration::from_secs(Self::EMAIL_CHANGE_COOLDOWN_SECS)),
).await {
tracing::warn!(error = %e, "Failed to set email change cooldown");
}
tracing::info!(new_email = %new_email, user_uid = %user_uid, "Email change verification sent");
Ok(())
}
@@ -148,6 +169,7 @@ impl AuthService {
self.ctx
.cache
.get::<PendingEmailChange>(&cache_key)
.await
.ok_or(AppError::NotFound(
"invalid or expired email verification token".into(),
))?;
@@ -195,7 +217,7 @@ impl AuthService {
txn.commit().await.map_err(|_| AppError::TxnError)?;
let _ = self.ctx.cache.delete(&cache_key);
let _ = self.ctx.cache.delete(&cache_key).await;
tracing::info!(new_email = %pending.new_email, user_uid = %pending.user_uid, "Email changed");
Ok(())
}
+1 -2
View File
@@ -7,8 +7,7 @@ impl AuthService {
if let Some(user_uid) = context.user() {
tracing::info!(user_uid = %user_uid, "User logged out");
}
context.clear_user();
context.clear();
context.purge();
Ok(())
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod captcha;
pub mod change_password;
pub mod email;
pub mod login;
pub mod logout;
+8 -4
View File
@@ -51,7 +51,7 @@ impl AuthService {
}
let cooldown_key = format!("{}cooldown:{}", Self::REGISTER_EMAIL_CODE_PREFIX, email);
if self.ctx.cache.exists(&cooldown_key) {
if self.ctx.cache.exists(&cooldown_key).await {
return Err(AppError::BadRequest(
"verification code was sent recently; please try again later".into(),
));
@@ -66,6 +66,7 @@ impl AuthService {
&code,
Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_TTL_SECS)),
)
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
self.ctx
.cache
@@ -74,6 +75,7 @@ impl AuthService {
&true,
Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_COOLDOWN_SECS)),
)
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let mut mail = self
@@ -101,17 +103,18 @@ impl AuthService {
})
}
fn auth_check_register_email_code(&self, email: &str, code: &str) -> Result<(), AppError> {
async fn auth_check_register_email_code(&self, email: &str, code: &str) -> Result<(), AppError> {
let cache_key = Self::register_email_code_key(email);
let stored = self
.ctx
.cache
.get::<String>(&cache_key)
.await
.ok_or(AppError::InvalidEmailCode)?;
if !crate::service::util::constant_time_eq(stored.trim(), code.trim()) {
return Err(AppError::InvalidEmailCode);
}
let _ = self.ctx.cache.delete(&cache_key);
let _ = self.ctx.cache.delete(&cache_key).await;
Ok(())
}
@@ -171,7 +174,7 @@ impl AuthService {
return Err(AppError::AccountAlreadyExists);
}
self.auth_check_register_email_code(&email, &params.email_code)?;
self.auth_check_register_email_code(&email, &params.email_code).await?;
let user_id = uuid::Uuid::now_v7();
let now = chrono::Utc::now();
@@ -230,6 +233,7 @@ impl AuthService {
txn.commit().await.map_err(|_| AppError::TxnError)?;
context.renew();
context.set_user(user_id);
context.remove(Self::RSA_PRIVATE_KEY);
context.remove(Self::RSA_PUBLIC_KEY);
+26 -24
View File
@@ -41,14 +41,14 @@ impl AuthService {
// Rate limiting: check cooldown
let cooldown_key = format!("{}cooldown:{}", Self::RESET_PASS_PREFIX, email);
if self.ctx.cache.exists(&cooldown_key) {
if self.ctx.cache.exists(&cooldown_key).await {
tracing::warn!(email = %email, "Password reset request rate limited (cooldown)");
return Ok(()); // Don't reveal if email exists
}
// Rate limiting: check daily limit
let daily_key = format!("{}daily:{}", Self::RESET_PASS_PREFIX, email);
let daily_count: u64 = self.ctx.cache.get(&daily_key).unwrap_or(0);
let daily_count: u64 = self.ctx.cache.get(&daily_key).await.unwrap_or(0);
if daily_count >= Self::RESET_PASS_DAILY_LIMIT {
tracing::warn!(email = %email, count = daily_count, "Password reset request rate limited (daily limit)");
return Ok(()); // Don't reveal if email exists
@@ -68,30 +68,11 @@ impl AuthService {
created_at: now,
},
Some(StdDuration::from_secs(Self::RESET_PASS_EXPIRY_SECS)),
) {
).await {
tracing::error!(error = %e, user_uid = %user.id, "Failed to cache reset token");
return Ok(());
}
// Set cooldown
if let Err(e) = self.ctx.cache.set(
&cooldown_key,
&true,
Some(StdDuration::from_secs(Self::RESET_PASS_COOLDOWN_SECS)),
) {
tracing::warn!(error = %e, "Failed to set cooldown");
}
// Increment daily counter
let new_count = daily_count + 1;
if let Err(e) = self.ctx.cache.set(
&daily_key,
&new_count,
Some(StdDuration::from_secs(Self::RESET_PASS_DAILY_SECS)),
) {
tracing::warn!(error = %e, "Failed to increment daily counter");
}
let domain = match self.ctx.config.main_domain() {
Ok(d) => d,
Err(e) => {
@@ -126,6 +107,26 @@ impl AuthService {
.await
{
tracing::error!(error = %e, email = %email, "Failed to send password reset email");
return Ok(());
}
// Set cooldown only after successful email send
if let Err(e) = self.ctx.cache.set(
&cooldown_key,
&true,
Some(StdDuration::from_secs(Self::RESET_PASS_COOLDOWN_SECS)),
).await {
tracing::warn!(error = %e, "Failed to set cooldown");
}
// Increment daily counter only after successful email send
let new_count = daily_count + 1;
if let Err(e) = self.ctx.cache.set(
&daily_key,
&new_count,
Some(StdDuration::from_secs(Self::RESET_PASS_DAILY_SECS)),
).await {
tracing::warn!(error = %e, "Failed to increment daily counter");
}
tracing::info!(email = %email, user_uid = %user.id, "Password reset email sent");
@@ -148,16 +149,17 @@ impl AuthService {
.ctx
.cache
.get::<PendingResetPassword>(&cache_key)
.await
.ok_or(AppError::InvalidResetToken)?;
if Utc::now() - pending.created_at > Duration::hours(Self::RESET_PASS_EXPIRY_HOURS) {
let _ = self.ctx.cache.delete(&cache_key);
let _ = self.ctx.cache.delete(&cache_key).await;
return Err(AppError::ResetTokenExpired);
}
let password = self.auth_rsa_decode(context, params.password).await?;
crate::service::util::validate_password_strength(&password)?;
let _ = self.ctx.cache.delete(&cache_key);
let _ = self.ctx.cache.delete(&cache_key).await;
let salt = SaltString::generate(&mut rand::thread_rng());
let password_hash = Argon2::default()
+17 -25
View File
@@ -192,13 +192,13 @@ impl AuthService {
let Some(totp_key) = context.get::<String>(Self::TOTP_KEY).ok().flatten() else {
return Ok(false);
};
let Some(user_uid) = self.ctx.cache.get::<Uuid>(&totp_key) else {
let Some(user_uid) = self.ctx.cache.get::<Uuid>(&totp_key).await else {
context.remove(Self::TOTP_KEY);
return Ok(false);
};
if user_uid != expected_user_uid {
context.remove(Self::TOTP_KEY);
let _ = self.ctx.cache.delete(&totp_key);
let _ = self.ctx.cache.delete(&totp_key).await;
tracing::warn!(expected_user_uid = %expected_user_uid, pending_user_uid = %user_uid, "2FA pending user mismatch");
return Ok(false);
}
@@ -206,7 +206,7 @@ impl AuthService {
let verified = self.auth_2fa_verify(user_uid, code).await?;
if verified {
context.remove(Self::TOTP_KEY);
let _ = self.ctx.cache.delete(&totp_key);
let _ = self.ctx.cache.delete(&totp_key).await;
}
Ok(verified)
}
@@ -349,7 +349,7 @@ impl AuthService {
async fn verify_user_password(&self, user_uid: Uuid, password: &str) -> Result<(), AppError> {
let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1")
.bind(user_uid)
.fetch_optional(self.ctx.db.reader())
.fetch_optional(self.ctx.db.writer())
.await
.map_err(AppError::Database)?
.ok_or(AppError::UserNotFound)?;
@@ -368,7 +368,7 @@ impl AuthService {
FROM user_2fa WHERE user_id = $1",
)
.bind(user_uid)
.fetch_optional(self.ctx.db.reader())
.fetch_optional(self.ctx.db.writer())
.await
.map_err(AppError::Database)
}
@@ -384,26 +384,18 @@ impl AuthService {
}
let hashed_code = self.hash_backup_code(code)?;
let mut backup_codes: Vec<String> = two_fa
.backup_codes
.split('.')
.filter(|c| !c.is_empty())
.map(ToOwned::to_owned)
.collect();
if backup_codes
.iter()
.any(|stored| constant_time_eq(stored, &hashed_code))
{
backup_codes.retain(|stored| stored != &hashed_code);
sqlx::query(
"UPDATE user_2fa SET backup_codes = $1, updated_at = $2 WHERE user_id = $3",
)
.bind(backup_codes.join("."))
.bind(chrono::Utc::now())
.bind(two_fa.user_id)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
let result = sqlx::query(
"UPDATE user_2fa SET backup_codes = regexp_replace(backup_codes, $1, ''), updated_at = $2 \
WHERE user_id = $3 AND backup_codes LIKE '%' || $1 || '%'",
)
.bind(&hashed_code)
.bind(chrono::Utc::now())
.bind(two_fa.user_id)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
if result.rows_affected() > 0 {
return Ok(true);
}
Ok(false)