Skip to main content

max / makenotwork

18.5 KB · 509 lines History Blame Raw
1 //! Profile updates, password, account deletion, stripe, email verification, appeals.
2
3 use axum::{
4 extract::State,
5 http::{header::HeaderMap, StatusCode},
6 response::{Html, IntoResponse, Response},
7 Form, Json,
8 };
9 use serde::{Deserialize, Serialize};
10 use tower_sessions::Session;
11
12 use crate::{
13 auth::AuthUser,
14 db::{self, UserId, Username},
15 email,
16 error::{AppError, Result, ResultExt},
17 helpers::is_htmx_request,
18 templates::{AlertTemplate, FormStatusTemplate, SaveStatusTemplate},
19 validation,
20 AppState,
21 };
22
23 use super::SuccessMessageResponse;
24
25 /// JSON response for profile updates.
26 #[derive(Debug, Serialize)]
27 struct ProfileResponse {
28 id: UserId,
29 username: Username,
30 display_name: Option<String>,
31 bio: Option<String>,
32 }
33
34 /// Form input for updating a user's display name and bio.
35 #[derive(Debug, Deserialize)]
36 pub struct UpdateProfileRequest {
37 pub display_name: Option<String>,
38 pub bio: Option<String>,
39 }
40
41 /// Update the authenticated user's display name and/or bio.
42 #[tracing::instrument(skip_all, name = "users::update_profile")]
43 pub(in crate::routes::api) async fn update_profile(
44 State(state): State<AppState>,
45 headers: HeaderMap,
46 AuthUser(user): AuthUser,
47 Form(req): Form<UpdateProfileRequest>,
48 ) -> Result<Response> {
49 user.check_not_suspended()?;
50 // Validate input
51 if let Some(ref name) = req.display_name {
52 validation::validate_display_name(name)?;
53 }
54 if let Some(ref bio) = req.bio {
55 validation::validate_bio(bio)?;
56 }
57
58 let updated = db::users::update_user_profile(
59 &state.db,
60 user.id,
61 req.display_name.as_deref(),
62 req.bio.as_deref(),
63 )
64 .await?;
65
66 if is_htmx_request(&headers) {
67 return Ok(Html(SaveStatusTemplate {
68 success: true,
69 message: "Profile saved".to_string(),
70 }.render_string()).into_response());
71 }
72
73 Ok(Json(ProfileResponse {
74 id: updated.id,
75 username: updated.username,
76 display_name: updated.display_name,
77 bio: updated.bio,
78 }).into_response())
79 }
80
81 /// Form input for changing the user's password.
82 #[derive(Debug, Deserialize)]
83 pub struct UpdatePasswordRequest {
84 pub current_password: String,
85 pub new_password: String,
86 }
87
88 /// Change the authenticated user's password after verifying the current one.
89 #[tracing::instrument(skip_all, name = "users::update_password")]
90 pub(in crate::routes::api) async fn update_password(
91 State(state): State<AppState>,
92 headers: HeaderMap,
93 session: Session,
94 AuthUser(user): AuthUser,
95 Form(req): Form<UpdatePasswordRequest>,
96 ) -> Result<Response> {
97 user.check_not_sandbox()?;
98 let is_htmx = is_htmx_request(&headers);
99
100 // Get current user with password hash
101 let db_user = db::users::get_user_by_id(&state.db, user.id)
102 .await?
103 .ok_or(AppError::NotFound)?;
104
105 // Verify current password
106 if !crate::auth::verify_password(&req.current_password, &db_user.password_hash)? {
107 if is_htmx {
108 return Ok(Html(SaveStatusTemplate {
109 success: false,
110 message: "Current password is incorrect".to_string(),
111 }.render_string()).into_response());
112 }
113 return Err(AppError::BadRequest("Current password is incorrect".to_string()));
114 }
115
116 // Validate new password
117 let password_len = req.new_password.chars().count();
118 if password_len < 8 {
119 if is_htmx {
120 return Ok(Html(SaveStatusTemplate {
121 success: false,
122 message: "New password must be at least 8 characters".to_string(),
123 }.render_string()).into_response());
124 }
125 return Err(AppError::validation(
126 "New password must be at least 8 characters".to_string(),
127 ));
128 }
129 if password_len > 128 {
130 if is_htmx {
131 return Ok(Html(SaveStatusTemplate {
132 success: false,
133 message: "Password must be 128 characters or fewer".to_string(),
134 }.render_string()).into_response());
135 }
136 return Err(AppError::validation(
137 "Password must be 128 characters or fewer".to_string(),
138 ));
139 }
140
141 // Check for breached password (advisory only, don't block)
142 if let Some(count) = crate::auth::check_password_breach(&req.new_password).await {
143 tracing::warn!(user_id = %user.id, event = "breached_password_change", breach_count = count, "User changed to breached password");
144 session.insert("password_warning", format!(
145 "This password has appeared in {} known data breach(es). Consider changing it.", count
146 )).await.ok();
147 }
148
149 // Hash and update. NOTE: `update_user_password` bumps `users.jwt_invalidated_at`
150 // in the SAME UPDATE as the password hash, which is what revokes outstanding
151 // SyncKit/OAuth bearer tokens (the `SyncUser` extractor rejects any JWT whose
152 // `iat <= jwt_invalidated_at`). The session sweep below only clears web session
153 // ROWS — it is deliberately NOT the JWT-revocation mechanism. Do not "fix" this
154 // by swapping in `delete_all_sessions_for_user`: that would also log the user
155 // out of their current web session, and the JWT bump already happened here.
156 let new_hash = crate::auth::hash_password(&req.new_password)?;
157 db::users::update_user_password(&state.db, user.id, &new_hash).await?;
158
159 // Invalidate all other sessions so stolen/leaked sessions can't survive a password change
160 let current_tracking_id = session
161 .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY)
162 .await
163 .ok()
164 .flatten();
165 if let Some(current_id) = current_tracking_id {
166 let revoked_ids = db::sessions::delete_other_sessions(&state.db, current_id, user.id).await?;
167 for id in &revoked_ids {
168 state.session_cache.remove(id);
169 }
170 if !revoked_ids.is_empty() {
171 tracing::info!(user_id = %user.id, revoked = revoked_ids.len(), event = "password_change_revoke_sessions", "Revoked other sessions on password change");
172 }
173 }
174
175 // Rotate session ID so old session cookie is invalidated
176 session.cycle_id().await
177 .context("session cycle")?;
178
179 if is_htmx {
180 return Ok(Html(SaveStatusTemplate {
181 success: true,
182 message: "Password updated".to_string(),
183 }.render_string()).into_response());
184 }
185
186 Ok(StatusCode::NO_CONTENT.into_response())
187 }
188
189 /// Permanently delete the authenticated user's account.
190 /// If the creator has completed sales, content is kept accessible for 90 days
191 /// so buyers can download their purchased files before removal.
192 #[tracing::instrument(skip_all, name = "users::delete_account")]
193 pub(in crate::routes::api) async fn delete_account(
194 State(state): State<AppState>,
195 AuthUser(user): AuthUser,
196 ) -> Result<impl IntoResponse> {
197 user.check_not_sandbox()?;
198
199 if db::users::has_completed_sales(&state.db, user.id).await? {
200 db::users::schedule_content_removal(&state.db, user.id).await?;
201 tracing::info!(user_id = %user.id, "creator account deletion scheduled with 90-day content grace period");
202
203 // Notify historical buyers (capped + Postmark-throttled). Fire-and-forget.
204 let pool = state.db.clone();
205 let email = state.email.clone();
206 let creator_name = user.display_name.clone()
207 .unwrap_or_else(|| user.username.to_string());
208 let user_id = user.id;
209 tokio::spawn(async move {
210 crate::email::send_creator_departure_notifications(&pool, &email, user_id, creator_name).await;
211 });
212 } else {
213 db::users::delete_user(&state.db, user.id).await?;
214 }
215
216 Ok(StatusCode::NO_CONTENT)
217 }
218
219 /// Self-deactivate account (enter limbo state).
220 #[tracing::instrument(skip_all, name = "users::deactivate_account")]
221 pub(in crate::routes::api) async fn deactivate_account(
222 State(state): State<AppState>,
223 AuthUser(user): AuthUser,
224 ) -> Result<impl IntoResponse> {
225 user.check_not_sandbox()?;
226 db::users::deactivate_user(&state.db, user.id).await?;
227 tracing::info!(user_id = %user.id, "user self-deactivated account");
228 Ok(StatusCode::NO_CONTENT)
229 }
230
231 /// Reactivate a self-deactivated account.
232 #[tracing::instrument(skip_all, name = "users::reactivate_account")]
233 pub(in crate::routes::api) async fn reactivate_account(
234 State(state): State<AppState>,
235 AuthUser(user): AuthUser,
236 ) -> Result<impl IntoResponse> {
237 db::users::reactivate_user(&state.db, user.id).await?;
238 tracing::info!(user_id = %user.id, "user reactivated account");
239 Ok(StatusCode::NO_CONTENT)
240 }
241
242 /// Voluntarily pause creator account. Cancels the creator tier subscription,
243 /// sets `cancel_at_period_end` on all active fan subscriptions (graceful expiry),
244 /// and blocks new purchases. Content remains hosted indefinitely.
245 #[tracing::instrument(skip_all, name = "users::pause_creator")]
246 pub(in crate::routes::api) async fn pause_creator(
247 State(state): State<AppState>,
248 AuthUser(user): AuthUser,
249 ) -> Result<impl IntoResponse> {
250 user.check_not_sandbox()?;
251
252 let db_user = db::users::get_user_by_id(&state.db, user.id)
253 .await?
254 .ok_or(AppError::NotFound)?;
255
256 if db_user.is_suspended() {
257 return Err(AppError::BadRequest("Cannot pause a suspended account".to_string()));
258 }
259 if db_user.is_deactivated() {
260 return Err(AppError::BadRequest("Cannot pause a deactivated account".to_string()));
261 }
262 if db_user.is_creator_paused() {
263 return Err(AppError::BadRequest("Account is already paused".to_string()));
264 }
265 if !db_user.can_create_projects {
266 return Err(AppError::BadRequest("Only creators can pause their account".to_string()));
267 }
268
269 if let Some(ref stripe) = state.stripe {
270 // Cancel the creator's own tier subscription on Stripe (platform-level)
271 if let Some(ct_sub) = db::creator_tiers::get_creator_sub_by_user(&state.db, user.id).await?
272 && ct_sub.status == db::SubscriptionStatus::Active
273 && let Err(e) = stripe.cancel_platform_subscription(&ct_sub.stripe_subscription_id).await
274 {
275 tracing::warn!(error = ?e, "failed to cancel creator tier subscription on Stripe during pause");
276 }
277
278 // Set cancel_at_period_end on all active fan subscriptions (connected account)
279 if let Some(ref stripe_account_id) = db_user.stripe_account_id {
280 let fan_subs = db::subscriptions::get_active_subscriptions_by_creator(&state.db, user.id).await?;
281 for sub in &fan_subs {
282 if let Err(e) = stripe.set_cancel_at_period_end(
283 &sub.stripe_subscription_id,
284 stripe_account_id,
285 true,
286 ).await {
287 tracing::warn!(
288 stripe_sub_id = %sub.stripe_subscription_id,
289 error = ?e,
290 "failed to set cancel_at_period_end on fan subscription during pause"
291 );
292 }
293 }
294 }
295 }
296
297 // Set the pause timestamp
298 db::users::pause_creator(&state.db, user.id).await?;
299 tracing::info!(user_id = %user.id, "creator paused account");
300
301 Ok(StatusCode::NO_CONTENT)
302 }
303
304 /// Disconnect the authenticated user's Stripe account.
305 #[tracing::instrument(skip_all, name = "users::disconnect_stripe")]
306 pub(in crate::routes::api) async fn disconnect_stripe(
307 State(state): State<AppState>,
308 AuthUser(user): AuthUser,
309 ) -> Result<impl IntoResponse> {
310 user.check_not_suspended()?;
311 db::users::disconnect_user_stripe(&state.db, user.id).await?;
312 Ok(StatusCode::NO_CONTENT)
313 }
314
315 /// Resend the email verification link to the authenticated user.
316 #[tracing::instrument(skip_all, name = "users::resend_verification")]
317 pub(in crate::routes::api) async fn resend_verification(
318 State(state): State<AppState>,
319 headers: HeaderMap,
320 AuthUser(user): AuthUser,
321 ) -> Result<Response> {
322 let is_htmx = is_htmx_request(&headers);
323
324 // Get full user data
325 let db_user = db::users::get_user_by_id(&state.db, user.id)
326 .await?
327 .ok_or(AppError::NotFound)?;
328
329 // Check if already verified
330 if db_user.email_verified {
331 if is_htmx {
332 return Ok(AlertTemplate::new("info", "Email already verified").into_response());
333 }
334 return Ok(Json(SuccessMessageResponse {
335 success: true,
336 message: "Email already verified",
337 }).into_response());
338 }
339
340 // Generate verification URL
341 let verify_url = email::generate_verification_url(
342 &state.config.host_url,
343 user.id,
344 &db_user.email,
345 &state.config.signing_secret,
346 );
347
348 // Send verification email
349 if let Err(e) = state.email
350 .send_verification(&db_user.email, db_user.display_name.as_deref(), &verify_url)
351 .await
352 {
353 if is_htmx {
354 tracing::error!(error = ?e, "failed to send verification email");
355 return Ok(AlertTemplate::new("error", "Failed to send verification email. Please try again.").into_response());
356 }
357 return Err(e);
358 }
359
360 tracing::info!(user_id = %user.id, "verification email sent");
361
362 if is_htmx {
363 return Ok(AlertTemplate::new("success", "Verification email sent. Check your inbox.").into_response());
364 }
365
366 Ok(Json(SuccessMessageResponse {
367 success: true,
368 message: "Verification email sent",
369 }).into_response())
370 }
371
372 /// Form input for requesting account deletion (requires username confirmation).
373 #[derive(Debug, Deserialize)]
374 pub struct RequestDeletionForm {
375 pub username: String,
376 }
377
378 /// Send an account deletion confirmation email after verifying the username.
379 #[tracing::instrument(skip_all, name = "users::request_account_deletion")]
380 pub(in crate::routes::api) async fn request_account_deletion(
381 State(state): State<AppState>,
382 headers: HeaderMap,
383 AuthUser(user): AuthUser,
384 Form(form): Form<RequestDeletionForm>,
385 ) -> Result<Response> {
386 user.check_not_sandbox()?;
387 let is_htmx = is_htmx_request(&headers);
388
389 // Get user from DB
390 let db_user = db::users::get_user_by_id(&state.db, user.id)
391 .await?
392 .ok_or(AppError::NotFound)?;
393
394 // Verify username matches (case-insensitive)
395 if form.username.to_lowercase() != db_user.username.to_lowercase() {
396 if is_htmx {
397 return Ok(Html(FormStatusTemplate {
398 success: false,
399 message: "Username does not match".to_string(),
400 }.render_string()).into_response());
401 }
402 return Err(AppError::BadRequest("Username does not match".to_string()));
403 }
404
405 // Generate deletion URL
406 let delete_url = email::generate_deletion_url(
407 &state.config.host_url,
408 user.id,
409 &db_user.email,
410 &state.config.signing_secret,
411 );
412
413 // Send deletion confirmation email
414 if let Err(e) = state.email
415 .send_deletion_confirmation(&db_user.email, db_user.display_name.as_deref(), &delete_url)
416 .await
417 {
418 if is_htmx {
419 tracing::error!(error = ?e, "failed to send deletion email");
420 return Ok(Html(FormStatusTemplate {
421 success: false,
422 message: "Failed to send email. Please try again.".to_string(),
423 }.render_string()).into_response());
424 }
425 return Err(e);
426 }
427
428 tracing::info!(user_id = %user.id, "deletion confirmation email sent");
429
430 if is_htmx {
431 return Ok(Html(FormStatusTemplate {
432 success: true,
433 message: "Confirmation email sent. Check your inbox.".to_string(),
434 }.render_string()).into_response());
435 }
436
437 Ok(Json(SuccessMessageResponse {
438 success: true,
439 message: "Deletion confirmation email sent",
440 }).into_response())
441 }
442
443 /// Form input for submitting a suspension appeal.
444 #[derive(Debug, Deserialize)]
445 pub struct AppealForm {
446 pub appeal_text: String,
447 }
448
449 /// Submit an appeal for a suspended account.
450 #[tracing::instrument(skip_all, name = "users::submit_appeal")]
451 pub(in crate::routes::api) async fn submit_appeal(
452 State(state): State<AppState>,
453 headers: HeaderMap,
454 AuthUser(user): AuthUser,
455 Form(form): Form<AppealForm>,
456 ) -> Result<Response> {
457 let is_htmx = is_htmx_request(&headers);
458
459 // Must be suspended to appeal
460 if !user.suspended {
461 if is_htmx {
462 return Ok(AlertTemplate::new("info", "Your account is not suspended.").into_response());
463 }
464 return Err(AppError::BadRequest("Account is not suspended".to_string()));
465 }
466
467 // Reject re-submission if a recent denial exists (within 30 days)
468 let db_user = db::users::get_user_by_id(&state.db, user.id)
469 .await?
470 .ok_or(AppError::NotFound)?;
471 if db_user.appeal_decision.as_deref() == Some("denied")
472 && let Some(decided_at) = db_user.appeal_decided_at
473 {
474 let days_since = (chrono::Utc::now() - decided_at).num_days();
475 if days_since < 30 {
476 let msg = format!("Your appeal was denied. You may resubmit after {} days.", 30 - days_since);
477 if is_htmx {
478 return Ok(AlertTemplate::new("error", &msg).into_response());
479 }
480 return Err(AppError::BadRequest(msg));
481 }
482 }
483 // Also reject if an appeal is already pending
484 if db_user.appeal_submitted_at.is_some() && db_user.appeal_decision.is_none() {
485 if is_htmx {
486 return Ok(AlertTemplate::new("info", "You already have a pending appeal.").into_response());
487 }
488 return Err(AppError::BadRequest("Appeal already pending".to_string()));
489 }
490
491 let appeal_text = form.appeal_text.trim();
492 if appeal_text.is_empty() || appeal_text.len() > 2000 {
493 if is_htmx {
494 return Ok(AlertTemplate::new("error", "Appeal must be between 1 and 2000 characters.").into_response());
495 }
496 return Err(AppError::validation("Appeal must be between 1 and 2000 characters".to_string()));
497 }
498
499 db::users::submit_appeal(&state.db, user.id, appeal_text).await?;
500
501 tracing::info!(user_id = %user.id, "suspension appeal submitted");
502
503 if is_htmx {
504 return Ok(AlertTemplate::new("success", "Appeal submitted. We'll review it as soon as possible.").into_response());
505 }
506
507 Ok(StatusCode::NO_CONTENT.into_response())
508 }
509