add initial crates and apps

This commit is contained in:
Evan Carroll 2026-01-12 15:34:40 -06:00
parent 5c87ba3519
commit 1ca300098f
113 changed files with 28169 additions and 0 deletions

View file

@ -0,0 +1,474 @@
//! Authentication API handlers.
use axum::{
extract::State,
Json,
};
use sqlx::PgPool;
use tower_sessions::Session;
use chattyness_db::{
models::{
AccountStatus, AuthenticatedUser, CurrentUserResponse, GuestLoginRequest,
GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest, LoginResponse,
LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole, RealmSummary,
SignupRequest, SignupResponse, UserSummary,
},
queries::{guests, memberships, realms, users},
};
use chattyness_error::AppError;
use crate::auth::{
session::{
hash_token, generate_token, SESSION_CURRENT_REALM_KEY, SESSION_GUEST_ID_KEY,
SESSION_LOGIN_TYPE_KEY, SESSION_ORIGINAL_DEST_KEY, SESSION_USER_ID_KEY,
},
AuthUser, OptionalAuthUser,
};
/// Get current user info.
pub async fn get_current_user(
State(pool): State<PgPool>,
OptionalAuthUser(user): OptionalAuthUser,
) -> Result<Json<CurrentUserResponse>, AppError> {
match user {
Some(user) => {
// Get staff role if any
let staff_role = memberships::get_user_staff_role(&pool, user.id).await?;
Ok(Json(CurrentUserResponse {
user: Some(AuthenticatedUser {
id: user.id,
username: user.username,
display_name: user.display_name,
avatar_url: user.avatar_url,
staff_role,
}),
}))
}
None => Ok(Json(CurrentUserResponse { user: None })),
}
}
/// Login handler.
pub async fn login(
rls_conn: crate::auth::RlsConn,
State(pool): State<PgPool>,
session: Session,
Json(req): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, AppError> {
// Validate the request
req.validate()?;
// Verify credentials
let user = users::verify_password_with_reset_flag(&pool, &req.username, &req.password)
.await?
.ok_or(AppError::InvalidCredentials)?;
// Set RLS context to the authenticated user for subsequent operations
rls_conn.set_user_id(user.id).await
.map_err(|e| AppError::Internal(format!("Failed to set RLS context: {}", e)))?;
// Check account status
if user.status != AccountStatus::Active {
return Err(AppError::AccountSuspended);
}
// Create user summary for response
let user_summary = UserSummary {
id: user.id,
username: user.username.clone(),
display_name: user.display_name.clone(),
avatar_url: user.avatar_url.clone(),
};
// Handle based on login type
match req.login_type {
LoginType::Staff => {
// Verify user is a staff member
let staff_role = memberships::get_user_staff_role(&pool, user.id)
.await?
.ok_or(AppError::NotStaffMember)?;
// Store session data
session
.insert(SESSION_USER_ID_KEY, user.id)
.await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
session
.insert(SESSION_LOGIN_TYPE_KEY, "staff")
.await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
// Check for forced password reset
if user.force_pw_reset {
session
.insert(SESSION_ORIGINAL_DEST_KEY, "/staff")
.await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
return Ok(Json(LoginResponse {
user: user_summary,
redirect_url: "/password-reset".to_string(),
requires_pw_reset: true,
is_member: None,
original_destination: Some("/staff".to_string()),
staff_role: Some(staff_role),
realm: None,
}));
}
Ok(Json(LoginResponse {
user: user_summary,
redirect_url: "/staff".to_string(),
requires_pw_reset: false,
is_member: None,
original_destination: None,
staff_role: Some(staff_role),
realm: None,
}))
}
LoginType::Realm => {
let realm_slug = req.realm_slug.as_ref().ok_or_else(|| {
AppError::Validation("Realm slug is required for realm login".to_string())
})?;
// Get the realm
let realm = realms::get_realm_by_slug(&pool, realm_slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", realm_slug)))?;
// Check if user is a member
let is_member = memberships::is_member(&pool, user.id, realm.id).await?;
// Store session data
session
.insert(SESSION_USER_ID_KEY, user.id)
.await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
session
.insert(SESSION_LOGIN_TYPE_KEY, "realm")
.await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
session
.insert(SESSION_CURRENT_REALM_KEY, realm.id)
.await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
let redirect_url = format!("/realms/{}", realm.slug);
// Check for forced password reset
if user.force_pw_reset {
session
.insert(SESSION_ORIGINAL_DEST_KEY, redirect_url.clone())
.await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
return Ok(Json(LoginResponse {
user: user_summary,
redirect_url: "/password-reset".to_string(),
requires_pw_reset: true,
is_member: Some(is_member),
original_destination: Some(redirect_url),
staff_role: None,
realm: Some(RealmSummary {
id: realm.id,
name: realm.name,
slug: realm.slug,
tagline: realm.tagline,
privacy: realm.privacy,
is_nsfw: realm.is_nsfw,
thumbnail_path: realm.thumbnail_path,
member_count: realm.member_count,
current_user_count: realm.current_user_count,
}),
}));
}
// If not a member, include realm info for join confirmation
if !is_member {
return Ok(Json(LoginResponse {
user: user_summary,
redirect_url: redirect_url.clone(),
requires_pw_reset: false,
is_member: Some(false),
original_destination: None,
staff_role: None,
realm: Some(RealmSummary {
id: realm.id,
name: realm.name,
slug: realm.slug,
tagline: realm.tagline,
privacy: realm.privacy,
is_nsfw: realm.is_nsfw,
thumbnail_path: realm.thumbnail_path,
member_count: realm.member_count,
current_user_count: realm.current_user_count,
}),
}));
}
// User is a member, update last visited (using RLS connection)
let mut conn = rls_conn.acquire().await;
memberships::update_last_visited_conn(&mut *conn, user.id, realm.id).await?;
Ok(Json(LoginResponse {
user: user_summary,
redirect_url,
requires_pw_reset: false,
is_member: Some(true),
original_destination: None,
staff_role: None,
realm: None,
}))
}
}
}
/// Logout response.
#[derive(Debug, serde::Serialize)]
pub struct LogoutResponse {
pub success: bool,
pub redirect_url: String,
}
/// Logout handler.
pub async fn logout(session: Session) -> Result<Json<LogoutResponse>, AppError> {
// Flush the session (removes all data and invalidates the session)
session
.flush()
.await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
Ok(Json(LogoutResponse {
success: true,
redirect_url: "/".to_string(),
}))
}
/// Signup handler.
pub async fn signup(
rls_conn: crate::auth::RlsConn,
State(pool): State<PgPool>,
session: Session,
Json(req): Json<SignupRequest>,
) -> Result<Json<SignupResponse>, AppError> {
// Validate the request
req.validate()?;
// Check username availability (can use pool for read-only checks)
if users::username_exists(&pool, &req.username).await? {
return Err(AppError::Conflict("Username already taken".to_string()));
}
// Check email availability if provided
if let Some(ref email) = req.email {
let email_trimmed = email.trim();
if !email_trimmed.is_empty() && users::email_exists(&pool, email_trimmed).await? {
return Err(AppError::Conflict("Email already registered".to_string()));
}
}
// Get the realm
let realm = realms::get_realm_by_slug(&pool, &req.realm_slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", req.realm_slug)))?;
// Create the user using RLS connection
let email_opt = req.email.as_ref().and_then(|e| {
let trimmed = e.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
});
let mut conn = rls_conn.acquire().await;
let user_id =
users::create_user_conn(&mut *conn, &req.username, email_opt, req.display_name.trim(), &req.password)
.await?;
drop(conn);
// Set RLS context to the new user for membership creation
rls_conn.set_user_id(user_id).await
.map_err(|e| AppError::Internal(format!("Failed to set RLS context: {}", e)))?;
// Create membership using RLS connection (now has user context)
let mut conn = rls_conn.acquire().await;
let membership_id =
memberships::create_membership_conn(&mut *conn, user_id, realm.id, RealmRole::Member).await?;
// Set up session (user is logged in)
session
.insert(SESSION_USER_ID_KEY, user_id)
.await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
session
.insert(SESSION_LOGIN_TYPE_KEY, "realm")
.await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
session
.insert(SESSION_CURRENT_REALM_KEY, realm.id)
.await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
let redirect_url = format!("/realms/{}", realm.slug);
Ok(Json(SignupResponse {
user: UserSummary {
id: user_id,
username: req.username,
display_name: req.display_name.trim().to_string(),
avatar_url: None,
},
redirect_url,
membership_id,
}))
}
/// Guest login handler.
pub async fn guest_login(
State(pool): State<PgPool>,
session: Session,
Json(req): Json<GuestLoginRequest>,
) -> Result<Json<GuestLoginResponse>, AppError> {
// Validate the request
req.validate()?;
// Get the realm
let realm = realms::get_realm_by_slug(&pool, &req.realm_slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", req.realm_slug)))?;
// Check if realm allows guest access
if !realm.allow_guest_access {
return Err(AppError::Forbidden(
"This realm does not allow guest access".to_string(),
));
}
// Generate guest name and session token
let guest_name = guests::generate_guest_name();
let token = generate_token();
let token_hash = hash_token(&token);
let expires_at = guests::guest_session_expiry();
// Create guest session in database
let guest_id = guests::create_guest_session(
&pool,
&guest_name,
realm.id,
&token_hash,
None, // user_agent
None, // ip_address
expires_at,
)
.await?;
// Set up tower session
session
.insert(SESSION_GUEST_ID_KEY, guest_id)
.await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
session
.insert(SESSION_LOGIN_TYPE_KEY, "guest")
.await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
session
.insert(SESSION_CURRENT_REALM_KEY, realm.id)
.await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
let redirect_url = format!("/realms/{}", realm.slug);
Ok(Json(GuestLoginResponse {
guest_name,
guest_id,
redirect_url,
realm: RealmSummary {
id: realm.id,
name: realm.name,
slug: realm.slug,
tagline: realm.tagline,
privacy: realm.privacy,
is_nsfw: realm.is_nsfw,
thumbnail_path: realm.thumbnail_path,
member_count: realm.member_count,
current_user_count: realm.current_user_count,
},
}))
}
/// Join realm handler.
pub async fn join_realm(
rls_conn: crate::auth::RlsConn,
State(pool): State<PgPool>,
AuthUser(user): AuthUser,
Json(req): Json<JoinRealmRequest>,
) -> Result<Json<JoinRealmResponse>, AppError> {
// Get the realm to verify it exists and check privacy
let realm = realms::get_realm_by_id(&pool, req.realm_id)
.await?
.ok_or_else(|| AppError::NotFound("Realm not found".to_string()))?;
// Check if user is already a member
let is_member = memberships::is_member(&pool, user.id, realm.id).await?;
if is_member {
return Err(AppError::Conflict(
"Already a member of this realm".to_string(),
));
}
// For private realms, don't allow direct join (would need invitation)
if realm.privacy == chattyness_db::models::RealmPrivacy::Private {
return Err(AppError::Forbidden(
"Cannot join private realms without an invitation".to_string(),
));
}
// Create the membership using RLS connection (policy requires user_id = current_user_id)
let mut conn = rls_conn.acquire().await;
let membership_id =
memberships::create_membership_conn(&mut *conn, user.id, realm.id, RealmRole::Member).await?;
Ok(Json(JoinRealmResponse {
success: true,
membership_id,
redirect_url: format!("/realms/{}", realm.slug),
}))
}
/// Password reset handler.
pub async fn reset_password(
rls_conn: crate::auth::RlsConn,
session: Session,
AuthUser(user): AuthUser,
Json(req): Json<PasswordResetRequest>,
) -> Result<Json<PasswordResetResponse>, AppError> {
// Validate the request
req.validate()?;
// Update the password using RLS connection (required for RLS policy)
let mut conn = rls_conn.acquire().await;
users::update_password_conn(&mut *conn, user.id, &req.new_password).await?;
// Get the original destination from session
let original_dest: Option<String> = session
.get(SESSION_ORIGINAL_DEST_KEY)
.await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
// Clear the original destination from session
session
.remove::<String>(SESSION_ORIGINAL_DEST_KEY)
.await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
let redirect_url = original_dest.unwrap_or_else(|| "/".to_string());
Ok(Json(PasswordResetResponse {
success: true,
redirect_url,
}))
}