//! Profile updates, password, account deletion, stripe, email verification, appeals. use axum::{ Json, extract::State, http::{StatusCode, header::HeaderMap}, response::{Html, IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use tower_sessions::Session; use crate::{ AppCaches, Billing, Integrations, auth::AuthUser, background::BackgroundTx, config::Config, db::{self, UserId, Username}, email::{self, EmailClient}, error::{AppError, Result, ResultExt}, helpers::is_htmx_request, templates::{AlertTemplate, FormStatusTemplate, SaveStatusTemplate}, validation, }; use super::SuccessMessageResponse; use crate::extractors::ValidatedForm; /// JSON response for profile updates. #[derive(Debug, Serialize)] struct ProfileResponse { id: UserId, username: Username, display_name: Option, bio: Option, } /// Form input for updating a user's display name and bio. #[derive(Debug, Deserialize)] pub(crate) struct UpdateProfileRequest { pub display_name: Option, pub bio: Option, } /// Update the authenticated user's display name and/or bio. #[tracing::instrument(skip_all, name = "users::update_profile")] pub(in crate::routes::api) async fn update_profile( State(db): State, headers: HeaderMap, AuthUser(user): AuthUser, ValidatedForm(req): ValidatedForm, ) -> Result { user.check_not_suspended()?; let is_htmx = is_htmx_request(&headers); // Validate input. On failure an HTMX request gets the inline status fragment // (mirroring update_password) instead of a full-page ErrorTemplate swapped // into the inline status span (UX-W1, Run 9). let validated = (|| -> Result<()> { if let Some(ref name) = req.display_name { validation::validate_display_name(name)?; } if let Some(ref bio) = req.bio { validation::validate_bio(bio)?; } Ok(()) })(); if let Err(e) = validated { if is_htmx { return Ok(Html( SaveStatusTemplate { success: false, message: e.user_message(), } .render_string()?, ) .into_response()); } return Err(e); } let updated = db::users::update_user_profile( &db, user.id, req.display_name.as_deref(), req.bio.as_deref(), ) .await?; if is_htmx { return Ok(Html( SaveStatusTemplate { success: true, message: "Profile saved".to_string(), } .render_string()?, ) .into_response()); } Ok(Json(ProfileResponse { id: updated.id, username: updated.username, display_name: updated.display_name, bio: updated.bio, }) .into_response()) } /// Form input for choosing the creator's public-profile theme (Tier 0). #[derive(Debug, Deserialize)] pub(crate) struct UpdateThemeRequest { /// Built-in theme id. Empty or absent clears to the platform default. pub theme_id: Option, } /// Set the authenticated user's public-profile theme. #[tracing::instrument(skip_all, name = "users::update_profile_theme")] pub(in crate::routes::api) async fn update_profile_theme( State(db): State, headers: HeaderMap, AuthUser(user): AuthUser, ValidatedForm(req): ValidatedForm, ) -> Result { user.check_not_suspended()?; let theme_id = crate::theming::normalize_theme_id(req.theme_id.as_deref()) .map_err(|id| AppError::validation(format!("Unknown theme: {id}")))?; db::users::update_user_theme(&db, user.id, theme_id.as_deref()).await?; if is_htmx_request(&headers) { return Ok(Html( SaveStatusTemplate { success: true, message: "Theme saved".to_string(), } .render_string()?, ) .into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) } /// Form input for changing the user's password. #[derive(Debug, Deserialize)] pub(crate) struct UpdatePasswordRequest { pub current_password: String, pub new_password: String, } /// Change the authenticated user's password after verifying the current one. #[tracing::instrument(skip_all, name = "users::update_password")] pub(in crate::routes::api) async fn update_password( State(db): State, State(caches): State, headers: HeaderMap, session: Session, AuthUser(user): AuthUser, ValidatedForm(req): ValidatedForm, ) -> Result { user.check_not_sandbox()?; let is_htmx = is_htmx_request(&headers); // Get current user with password hash let db_user = db::users::get_user_by_id(&db, user.id) .await? .ok_or(AppError::NotFound)?; // Verify current password if !crate::auth::verify_password_async( req.current_password.clone(), db_user.password_hash.clone(), ) .await? { if is_htmx { return Ok(Html( SaveStatusTemplate { success: false, message: "Current password is incorrect".to_string(), } .render_string()?, ) .into_response()); } return Err(AppError::BadRequest( "Current password is incorrect".to_string(), )); } // Validate new password let password_len = req.new_password.chars().count(); if password_len < crate::validation::limits::PASSWORD_MIN { if is_htmx { return Ok(Html( SaveStatusTemplate { success: false, message: "New password must be at least 8 characters".to_string(), } .render_string()?, ) .into_response()); } return Err(AppError::validation( "New password must be at least 8 characters".to_string(), )); } if crate::validation::password_too_long(&req.new_password) { if is_htmx { return Ok(Html( SaveStatusTemplate { success: false, message: "Password must be 128 characters or fewer".to_string(), } .render_string()?, ) .into_response()); } return Err(AppError::validation( "Password must be 128 characters or fewer".to_string(), )); } // Check for breached password (advisory only, don't block) if let Some(count) = crate::auth::check_password_breach(&req.new_password).await { tracing::warn!(user_id = %user.id, event = "breached_password_change", breach_count = count, "User changed to breached password"); session .insert( "password_warning", format!( "This password has appeared in {count} known data breach(es). Consider changing it." ), ) .await .ok(); } // Hash and update. NOTE: `update_user_password` bumps `users.jwt_invalidated_at` // in the SAME UPDATE as the password hash, which is what revokes outstanding // SyncKit/OAuth bearer tokens (the `SyncUser` extractor rejects any JWT whose // `iat <= jwt_invalidated_at`). The session sweep below only clears web session // ROWS, it is deliberately NOT the JWT-revocation mechanism. Do not "fix" this // by swapping in `delete_all_sessions_for_user`: that would also log the user // out of their current web session, and the JWT bump already happened here. let new_hash = crate::auth::hash_password_async(req.new_password.clone()).await?; db::users::update_user_password(&db, user.id, &new_hash).await?; // Invalidate all other sessions so stolen/leaked sessions can't survive a password change let current_tracking_id = session .get::(crate::auth::SESSION_TRACKING_KEY) .await .ok() .flatten(); let revoked_ids = if let Some(current_id) = current_tracking_id { db::sessions::delete_other_sessions(&db, current_id, user.id).await? } else { // Legacy current session: no SESSION_TRACKING_KEY, so it has no // `user_sessions` row and the auth extractor never validates it against // that table (auth.rs). Deleting ALL of the user's tracked sessions // therefore revokes every OTHER device's cookie without logging out // this untracked one, so the note above (don't swap delete_all into // the *tracked* branch, where it WOULD kill the current row) still // holds. Other untracked legacy sessions can't be targeted by id and // are left to expire. db::sessions::delete_all_sessions_for_user(&db, user.id).await? }; for id in &revoked_ids { caches.session_cache.remove(id); } if !revoked_ids.is_empty() { tracing::info!(user_id = %user.id, revoked = revoked_ids.len(), event = "password_change_revoke_sessions", "Revoked other sessions on password change"); } // Rotate session ID so old session cookie is invalidated session.cycle_id().await.context("session cycle")?; if is_htmx { return Ok(Html( SaveStatusTemplate { success: true, message: "Password updated".to_string(), } .render_string()?, ) .into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) } /// Permanently delete the authenticated user's account. /// If the creator has completed sales, content is kept accessible for 90 days /// so buyers can download their purchased files before removal. #[tracing::instrument(skip_all, name = "users::delete_account")] pub(in crate::routes::api) async fn delete_account( State(db): State, State(mailer): State, State(caches): State, AuthUser(user): AuthUser, ) -> Result { user.check_not_sandbox()?; if db::users::has_completed_sales(&db, user.id).await? { db::users::schedule_content_removal(&db, user.id).await?; tracing::info!(user_id = %user.id, "creator account deletion scheduled with 90-day content grace period"); // Notify historical buyers (capped + Postmark-throttled). Fire-and-forget. let pool = db.clone(); let email = mailer.clone(); let creator_name = user .display_name .clone() .unwrap_or_else(|| user.username.to_string()); let user_id = user.id; tokio::spawn(async move { crate::email::send_creator_departure_notifications( &pool, &email, user_id, creator_name, ) .await; }); } else { crate::delete_user_account(&db, &caches, user.id).await?; } Ok(StatusCode::NO_CONTENT) } /// Self-deactivate account (enter limbo state). #[tracing::instrument(skip_all, name = "users::deactivate_account")] pub(in crate::routes::api) async fn deactivate_account( State(db): State, AuthUser(user): AuthUser, ) -> Result { user.check_not_sandbox()?; db::users::deactivate_user(&db, user.id).await?; tracing::info!(user_id = %user.id, "user self-deactivated account"); Ok(StatusCode::NO_CONTENT) } /// Reactivate a self-deactivated account. #[tracing::instrument(skip_all, name = "users::reactivate_account")] pub(in crate::routes::api) async fn reactivate_account( State(db): State, AuthUser(user): AuthUser, ) -> Result { db::users::reactivate_user(&db, user.id).await?; tracing::info!(user_id = %user.id, "user reactivated account"); Ok(StatusCode::NO_CONTENT) } /// Voluntarily pause creator account. Cancels the creator tier subscription, /// sets `cancel_at_period_end` on all active fan subscriptions (graceful expiry), /// and blocks new purchases. Content remains hosted indefinitely. #[tracing::instrument(skip_all, name = "users::pause_creator")] pub(in crate::routes::api) async fn pause_creator( State(db): State, State(payments): State, State(bg): State, State(integrations): State, AuthUser(user): AuthUser, ) -> Result { user.check_not_sandbox()?; let db_user = db::users::get_user_by_id(&db, user.id) .await? .ok_or(AppError::NotFound)?; if db_user.is_suspended() { return Err(AppError::BadRequest( "Cannot pause a suspended account".to_string(), )); } if db_user.is_deactivated() { return Err(AppError::BadRequest( "Cannot pause a deactivated account".to_string(), )); } if db_user.is_creator_paused() { return Err(AppError::BadRequest( "Account is already paused".to_string(), )); } if !db_user.can_create_projects { return Err(AppError::BadRequest( "Only creators can pause their account".to_string(), )); } if let Some(ref stripe) = payments.stripe { // Cancel the creator's own tier subscription on Stripe (platform-level) if let Some(ct_sub) = db::creator_tiers::get_creator_sub_by_user(&db, user.id).await? && ct_sub.status == db::SubscriptionStatus::Active && let Err(e) = stripe .cancel_platform_subscription(&ct_sub.stripe_subscription_id) .await { tracing::warn!(error = ?e, "failed to cancel creator tier subscription on Stripe during pause"); } // Set cancel_at_period_end on all active fan subscriptions (connected // account). Fanned out on the background queue so a creator with many // fans doesn't tie the request up for N serial Stripe round-trips. if let Some(ref stripe_account_id) = db_user.stripe_account_id { let fan_subs = db::subscriptions::get_active_subscriptions_by_creator(&db, user.id).await?; let ids = fan_subs .into_iter() .map(|s| s.stripe_subscription_id) .collect(); crate::payments::fan_ops::spawn_fan_sub_fanout( &bg, std::sync::Arc::clone(stripe), stripe_account_id.clone(), ids, crate::payments::fan_ops::FanSubOp::CancelAtPeriodEnd(true), integrations.wam.clone(), ); } } // Set the pause timestamp db::users::pause_creator(&db, user.id).await?; tracing::info!(user_id = %user.id, "creator paused account"); Ok(StatusCode::NO_CONTENT) } /// Disconnect the authenticated user's Stripe account. #[tracing::instrument(skip_all, name = "users::disconnect_stripe")] pub(in crate::routes::api) async fn disconnect_stripe( State(db): State, AuthUser(user): AuthUser, ) -> Result { user.check_not_suspended()?; db::users::disconnect_user_stripe(&db, user.id).await?; Ok(StatusCode::NO_CONTENT) } /// Resend the email verification link to the authenticated user. #[tracing::instrument(skip_all, name = "users::resend_verification")] pub(in crate::routes::api) async fn resend_verification( State(db): State, State(config): State, State(email): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> Result { let is_htmx = is_htmx_request(&headers); // Get full user data let db_user = db::users::get_user_by_id(&db, user.id) .await? .ok_or(AppError::NotFound)?; // Check if already verified if db_user.email_verified { if is_htmx { return Ok(AlertTemplate::new("info", "Email already verified").into_response()); } return Ok(Json(SuccessMessageResponse { success: true, message: "Email already verified", }) .into_response()); } let verify_url = email::generate_verification_url( &config.host_url, user.id, &db_user.email, &config.signing_secret, ); // Send verification email if let Err(e) = email .send_verification(&db_user.email, db_user.display_name.as_deref(), &verify_url) .await { if is_htmx { tracing::error!(error = ?e, "failed to send verification email"); return Ok(AlertTemplate::new( "error", "Failed to send verification email. Please try again.", ) .into_response()); } return Err(e); } tracing::info!(user_id = %user.id, "verification email sent"); if is_htmx { return Ok( AlertTemplate::new("success", "Verification email sent. Check your inbox.") .into_response(), ); } Ok(Json(SuccessMessageResponse { success: true, message: "Verification email sent", }) .into_response()) } /// Form input for requesting account deletion (requires username confirmation). #[derive(Debug, Deserialize)] pub(crate) struct RequestDeletionForm { pub username: String, } /// Send an account deletion confirmation email after verifying the username. #[tracing::instrument(skip_all, name = "users::request_account_deletion")] pub(in crate::routes::api) async fn request_account_deletion( State(db): State, State(config): State, State(email): State, headers: HeaderMap, AuthUser(user): AuthUser, ValidatedForm(form): ValidatedForm, ) -> Result { user.check_not_sandbox()?; let is_htmx = is_htmx_request(&headers); // Get user from DB let db_user = db::users::get_user_by_id(&db, user.id) .await? .ok_or(AppError::NotFound)?; // Verify username matches (case-insensitive) if form.username.to_lowercase() != db_user.username.to_lowercase() { if is_htmx { return Ok(Html( FormStatusTemplate { success: false, message: "Username does not match".to_string(), } .render_string()?, ) .into_response()); } return Err(AppError::BadRequest("Username does not match".to_string())); } let delete_url = email::generate_deletion_url( &config.host_url, user.id, &db_user.email, &config.signing_secret, ); // Send deletion confirmation email if let Err(e) = email .send_deletion_confirmation(&db_user.email, db_user.display_name.as_deref(), &delete_url) .await { if is_htmx { tracing::error!(error = ?e, "failed to send deletion email"); return Ok(Html( FormStatusTemplate { success: false, message: "Failed to send email. Please try again.".to_string(), } .render_string()?, ) .into_response()); } return Err(e); } tracing::info!(user_id = %user.id, "deletion confirmation email sent"); if is_htmx { return Ok(Html( FormStatusTemplate { success: true, message: "Confirmation email sent. Check your inbox.".to_string(), } .render_string()?, ) .into_response()); } Ok(Json(SuccessMessageResponse { success: true, message: "Deletion confirmation email sent", }) .into_response()) } /// Form input for submitting a suspension appeal. #[derive(Debug, Deserialize)] pub(crate) struct AppealForm { pub appeal_text: String, } /// Submit an appeal for a suspended account. #[tracing::instrument(skip_all, name = "users::submit_appeal")] pub(in crate::routes::api) async fn submit_appeal( State(db): State, headers: HeaderMap, AuthUser(user): AuthUser, ValidatedForm(form): ValidatedForm, ) -> Result { let is_htmx = is_htmx_request(&headers); // Must be suspended to appeal if !user.suspended { if is_htmx { return Ok(AlertTemplate::new("info", "Your account is not suspended.").into_response()); } return Err(AppError::BadRequest("Account is not suspended".to_string())); } // Reject re-submission if a recent denial exists (within 30 days) let db_user = db::users::get_user_by_id(&db, user.id) .await? .ok_or(AppError::NotFound)?; if db_user.appeal_decision.as_deref() == Some("denied") && let Some(decided_at) = db_user.appeal_decided_at { let days_since = (chrono::Utc::now() - decided_at).num_days(); if days_since < 30 { let msg = format!( "Your appeal was denied. You may resubmit after {} days.", 30 - days_since ); if is_htmx { return Ok(AlertTemplate::new("error", &msg).into_response()); } return Err(AppError::BadRequest(msg)); } } // Also reject if an appeal is already pending if db_user.appeal_submitted_at.is_some() && db_user.appeal_decision.is_none() { if is_htmx { return Ok( AlertTemplate::new("info", "You already have a pending appeal.").into_response(), ); } return Err(AppError::BadRequest("Appeal already pending".to_string())); } let appeal_text = form.appeal_text.trim(); if appeal_text.is_empty() || appeal_text.len() > 2000 { if is_htmx { return Ok(AlertTemplate::new( "error", "Appeal must be between 1 and 2000 characters.", ) .into_response()); } return Err(AppError::validation( "Appeal must be between 1 and 2000 characters".to_string(), )); } db::users::submit_appeal(&db, user.id, appeal_text).await?; tracing::info!(user_id = %user.id, "suspension appeal submitted"); if is_htmx { return Ok(AlertTemplate::new( "success", "Appeal submitted. We'll review it as soon as possible.", ) .into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) }