Skip to main content

max / makenotwork

22.3 KB · 674 lines History Blame Raw
1 //! Profile updates, password, account deletion, stripe, email verification, appeals.
2
3 use axum::{
4 Json,
5 extract::State,
6 http::{StatusCode, header::HeaderMap},
7 response::{Html, IntoResponse, Response},
8 };
9 use serde::{Deserialize, Serialize};
10 use sqlx::PgPool;
11 use tower_sessions::Session;
12
13 use crate::{
14 AppCaches, Billing, Integrations,
15 auth::AuthUser,
16 background::BackgroundTx,
17 config::Config,
18 db::{self, UserId, Username},
19 email::{self, EmailClient},
20 error::{AppError, Result, ResultExt},
21 helpers::is_htmx_request,
22 templates::{AlertTemplate, FormStatusTemplate, SaveStatusTemplate},
23 validation,
24 };
25
26 use super::SuccessMessageResponse;
27 use crate::extractors::ValidatedForm;
28
29 /// JSON response for profile updates.
30 #[derive(Debug, Serialize)]
31 struct ProfileResponse {
32 id: UserId,
33 username: Username,
34 display_name: Option<String>,
35 bio: Option<String>,
36 }
37
38 /// Form input for updating a user's display name and bio.
39 #[derive(Debug, Deserialize)]
40 pub(crate) struct UpdateProfileRequest {
41 pub display_name: Option<String>,
42 pub bio: Option<String>,
43 }
44
45 /// Update the authenticated user's display name and/or bio.
46 #[tracing::instrument(skip_all, name = "users::update_profile")]
47 pub(in crate::routes::api) async fn update_profile(
48 State(db): State<PgPool>,
49 headers: HeaderMap,
50 AuthUser(user): AuthUser,
51 ValidatedForm(req): ValidatedForm<UpdateProfileRequest>,
52 ) -> Result<Response> {
53 user.check_not_suspended()?;
54 let is_htmx = is_htmx_request(&headers);
55
56 // Validate input. On failure an HTMX request gets the inline status fragment
57 // (mirroring update_password) instead of a full-page ErrorTemplate swapped
58 // into the inline status span (UX-W1, Run 9).
59 let validated = (|| -> Result<()> {
60 if let Some(ref name) = req.display_name {
61 validation::validate_display_name(name)?;
62 }
63 if let Some(ref bio) = req.bio {
64 validation::validate_bio(bio)?;
65 }
66 Ok(())
67 })();
68 if let Err(e) = validated {
69 if is_htmx {
70 return Ok(Html(
71 SaveStatusTemplate {
72 success: false,
73 message: e.user_message(),
74 }
75 .render_string()?,
76 )
77 .into_response());
78 }
79 return Err(e);
80 }
81
82 let updated = db::users::update_user_profile(
83 &db,
84 user.id,
85 req.display_name.as_deref(),
86 req.bio.as_deref(),
87 )
88 .await?;
89
90 if is_htmx {
91 return Ok(Html(
92 SaveStatusTemplate {
93 success: true,
94 message: "Profile saved".to_string(),
95 }
96 .render_string()?,
97 )
98 .into_response());
99 }
100
101 Ok(Json(ProfileResponse {
102 id: updated.id,
103 username: updated.username,
104 display_name: updated.display_name,
105 bio: updated.bio,
106 })
107 .into_response())
108 }
109
110 /// Form input for choosing the creator's public-profile theme (Tier 0).
111 #[derive(Debug, Deserialize)]
112 pub(crate) struct UpdateThemeRequest {
113 /// Built-in theme id. Empty or absent clears to the platform default.
114 pub theme_id: Option<String>,
115 }
116
117 /// Set the authenticated user's public-profile theme.
118 #[tracing::instrument(skip_all, name = "users::update_profile_theme")]
119 pub(in crate::routes::api) async fn update_profile_theme(
120 State(db): State<PgPool>,
121 headers: HeaderMap,
122 AuthUser(user): AuthUser,
123 ValidatedForm(req): ValidatedForm<UpdateThemeRequest>,
124 ) -> Result<Response> {
125 user.check_not_suspended()?;
126 let theme_id = crate::theming::normalize_theme_id(req.theme_id.as_deref())
127 .map_err(|id| AppError::validation(format!("Unknown theme: {id}")))?;
128 db::users::update_user_theme(&db, user.id, theme_id.as_deref()).await?;
129
130 if is_htmx_request(&headers) {
131 return Ok(Html(
132 SaveStatusTemplate {
133 success: true,
134 message: "Theme saved".to_string(),
135 }
136 .render_string()?,
137 )
138 .into_response());
139 }
140 Ok(StatusCode::NO_CONTENT.into_response())
141 }
142
143 /// Form input for changing the user's password.
144 #[derive(Debug, Deserialize)]
145 pub(crate) struct UpdatePasswordRequest {
146 pub current_password: String,
147 pub new_password: String,
148 }
149
150 /// Change the authenticated user's password after verifying the current one.
151 #[tracing::instrument(skip_all, name = "users::update_password")]
152 pub(in crate::routes::api) async fn update_password(
153 State(db): State<PgPool>,
154 State(caches): State<AppCaches>,
155 headers: HeaderMap,
156 session: Session,
157 AuthUser(user): AuthUser,
158 ValidatedForm(req): ValidatedForm<UpdatePasswordRequest>,
159 ) -> Result<Response> {
160 user.check_not_sandbox()?;
161 let is_htmx = is_htmx_request(&headers);
162
163 // Get current user with password hash
164 let db_user = db::users::get_user_by_id(&db, user.id)
165 .await?
166 .ok_or(AppError::NotFound)?;
167
168 // Verify current password
169 if !crate::auth::verify_password_async(
170 req.current_password.clone(),
171 db_user.password_hash.clone(),
172 )
173 .await?
174 {
175 if is_htmx {
176 return Ok(Html(
177 SaveStatusTemplate {
178 success: false,
179 message: "Current password is incorrect".to_string(),
180 }
181 .render_string()?,
182 )
183 .into_response());
184 }
185 return Err(AppError::BadRequest(
186 "Current password is incorrect".to_string(),
187 ));
188 }
189
190 // Validate new password
191 let password_len = req.new_password.chars().count();
192 if password_len < crate::validation::limits::PASSWORD_MIN {
193 if is_htmx {
194 return Ok(Html(
195 SaveStatusTemplate {
196 success: false,
197 message: "New password must be at least 8 characters".to_string(),
198 }
199 .render_string()?,
200 )
201 .into_response());
202 }
203 return Err(AppError::validation(
204 "New password must be at least 8 characters".to_string(),
205 ));
206 }
207 if crate::validation::password_too_long(&req.new_password) {
208 if is_htmx {
209 return Ok(Html(
210 SaveStatusTemplate {
211 success: false,
212 message: "Password must be 128 characters or fewer".to_string(),
213 }
214 .render_string()?,
215 )
216 .into_response());
217 }
218 return Err(AppError::validation(
219 "Password must be 128 characters or fewer".to_string(),
220 ));
221 }
222
223 // Check for breached password (advisory only, don't block)
224 if let Some(count) = crate::auth::check_password_breach(&req.new_password).await {
225 tracing::warn!(user_id = %user.id, event = "breached_password_change", breach_count = count, "User changed to breached password");
226 session
227 .insert(
228 "password_warning",
229 format!(
230 "This password has appeared in {count} known data breach(es). Consider changing it."
231 ),
232 )
233 .await
234 .ok();
235 }
236
237 // Hash and update. NOTE: `update_user_password` bumps `users.jwt_invalidated_at`
238 // in the SAME UPDATE as the password hash, which is what revokes outstanding
239 // SyncKit/OAuth bearer tokens (the `SyncUser` extractor rejects any JWT whose
240 // `iat <= jwt_invalidated_at`). The session sweep below only clears web session
241 // ROWS, it is deliberately NOT the JWT-revocation mechanism. Do not "fix" this
242 // by swapping in `delete_all_sessions_for_user`: that would also log the user
243 // out of their current web session, and the JWT bump already happened here.
244 let new_hash = crate::auth::hash_password_async(req.new_password.clone()).await?;
245 db::users::update_user_password(&db, user.id, &new_hash).await?;
246
247 // Invalidate all other sessions so stolen/leaked sessions can't survive a password change
248 let current_tracking_id = session
249 .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY)
250 .await
251 .ok()
252 .flatten();
253 let revoked_ids = if let Some(current_id) = current_tracking_id {
254 db::sessions::delete_other_sessions(&db, current_id, user.id).await?
255 } else {
256 // Legacy current session: no SESSION_TRACKING_KEY, so it has no
257 // `user_sessions` row and the auth extractor never validates it against
258 // that table (auth.rs). Deleting ALL of the user's tracked sessions
259 // therefore revokes every OTHER device's cookie without logging out
260 // this untracked one, so the note above (don't swap delete_all into
261 // the *tracked* branch, where it WOULD kill the current row) still
262 // holds. Other untracked legacy sessions can't be targeted by id and
263 // are left to expire.
264 db::sessions::delete_all_sessions_for_user(&db, user.id).await?
265 };
266 for id in &revoked_ids {
267 caches.session_cache.remove(id);
268 }
269 if !revoked_ids.is_empty() {
270 tracing::info!(user_id = %user.id, revoked = revoked_ids.len(), event = "password_change_revoke_sessions", "Revoked other sessions on password change");
271 }
272
273 // Rotate session ID so old session cookie is invalidated
274 session.cycle_id().await.context("session cycle")?;
275
276 if is_htmx {
277 return Ok(Html(
278 SaveStatusTemplate {
279 success: true,
280 message: "Password updated".to_string(),
281 }
282 .render_string()?,
283 )
284 .into_response());
285 }
286
287 Ok(StatusCode::NO_CONTENT.into_response())
288 }
289
290 /// Permanently delete the authenticated user's account.
291 /// If the creator has completed sales, content is kept accessible for 90 days
292 /// so buyers can download their purchased files before removal.
293 #[tracing::instrument(skip_all, name = "users::delete_account")]
294 pub(in crate::routes::api) async fn delete_account(
295 State(db): State<PgPool>,
296 State(mailer): State<crate::email::EmailClient>,
297 State(caches): State<crate::AppCaches>,
298 AuthUser(user): AuthUser,
299 ) -> Result<impl IntoResponse> {
300 user.check_not_sandbox()?;
301
302 if db::users::has_completed_sales(&db, user.id).await? {
303 db::users::schedule_content_removal(&db, user.id).await?;
304 tracing::info!(user_id = %user.id, "creator account deletion scheduled with 90-day content grace period");
305
306 // Notify historical buyers (capped + Postmark-throttled). Fire-and-forget.
307 let pool = db.clone();
308 let email = mailer.clone();
309 let creator_name = user
310 .display_name
311 .clone()
312 .unwrap_or_else(|| user.username.to_string());
313 let user_id = user.id;
314 tokio::spawn(async move {
315 crate::email::send_creator_departure_notifications(
316 &pool,
317 &email,
318 user_id,
319 creator_name,
320 )
321 .await;
322 });
323 } else {
324 crate::delete_user_account(&db, &caches, user.id).await?;
325 }
326
327 Ok(StatusCode::NO_CONTENT)
328 }
329
330 /// Self-deactivate account (enter limbo state).
331 #[tracing::instrument(skip_all, name = "users::deactivate_account")]
332 pub(in crate::routes::api) async fn deactivate_account(
333 State(db): State<PgPool>,
334 AuthUser(user): AuthUser,
335 ) -> Result<impl IntoResponse> {
336 user.check_not_sandbox()?;
337 db::users::deactivate_user(&db, user.id).await?;
338 tracing::info!(user_id = %user.id, "user self-deactivated account");
339 Ok(StatusCode::NO_CONTENT)
340 }
341
342 /// Reactivate a self-deactivated account.
343 #[tracing::instrument(skip_all, name = "users::reactivate_account")]
344 pub(in crate::routes::api) async fn reactivate_account(
345 State(db): State<PgPool>,
346 AuthUser(user): AuthUser,
347 ) -> Result<impl IntoResponse> {
348 db::users::reactivate_user(&db, user.id).await?;
349 tracing::info!(user_id = %user.id, "user reactivated account");
350 Ok(StatusCode::NO_CONTENT)
351 }
352
353 /// Voluntarily pause creator account. Cancels the creator tier subscription,
354 /// sets `cancel_at_period_end` on all active fan subscriptions (graceful expiry),
355 /// and blocks new purchases. Content remains hosted indefinitely.
356 #[tracing::instrument(skip_all, name = "users::pause_creator")]
357 pub(in crate::routes::api) async fn pause_creator(
358 State(db): State<PgPool>,
359 State(payments): State<Billing>,
360 State(bg): State<BackgroundTx>,
361 State(integrations): State<Integrations>,
362 AuthUser(user): AuthUser,
363 ) -> Result<impl IntoResponse> {
364 user.check_not_sandbox()?;
365
366 let db_user = db::users::get_user_by_id(&db, user.id)
367 .await?
368 .ok_or(AppError::NotFound)?;
369
370 if db_user.is_suspended() {
371 return Err(AppError::BadRequest(
372 "Cannot pause a suspended account".to_string(),
373 ));
374 }
375 if db_user.is_deactivated() {
376 return Err(AppError::BadRequest(
377 "Cannot pause a deactivated account".to_string(),
378 ));
379 }
380 if db_user.is_creator_paused() {
381 return Err(AppError::BadRequest(
382 "Account is already paused".to_string(),
383 ));
384 }
385 if !db_user.can_create_projects {
386 return Err(AppError::BadRequest(
387 "Only creators can pause their account".to_string(),
388 ));
389 }
390
391 if let Some(ref stripe) = payments.stripe {
392 // Cancel the creator's own tier subscription on Stripe (platform-level)
393 if let Some(ct_sub) = db::creator_tiers::get_creator_sub_by_user(&db, user.id).await?
394 && ct_sub.status == db::SubscriptionStatus::Active
395 && let Err(e) = stripe
396 .cancel_platform_subscription(&ct_sub.stripe_subscription_id)
397 .await
398 {
399 tracing::warn!(error = ?e, "failed to cancel creator tier subscription on Stripe during pause");
400 }
401
402 // Set cancel_at_period_end on all active fan subscriptions (connected
403 // account). Fanned out on the background queue so a creator with many
404 // fans doesn't tie the request up for N serial Stripe round-trips.
405 if let Some(ref stripe_account_id) = db_user.stripe_account_id {
406 let fan_subs =
407 db::subscriptions::get_active_subscriptions_by_creator(&db, user.id).await?;
408 let ids = fan_subs
409 .into_iter()
410 .map(|s| s.stripe_subscription_id)
411 .collect();
412 crate::payments::fan_ops::spawn_fan_sub_fanout(
413 &bg,
414 std::sync::Arc::clone(stripe),
415 stripe_account_id.clone(),
416 ids,
417 crate::payments::fan_ops::FanSubOp::CancelAtPeriodEnd(true),
418 integrations.wam.clone(),
419 );
420 }
421 }
422
423 // Set the pause timestamp
424 db::users::pause_creator(&db, user.id).await?;
425 tracing::info!(user_id = %user.id, "creator paused account");
426
427 Ok(StatusCode::NO_CONTENT)
428 }
429
430 /// Disconnect the authenticated user's Stripe account.
431 #[tracing::instrument(skip_all, name = "users::disconnect_stripe")]
432 pub(in crate::routes::api) async fn disconnect_stripe(
433 State(db): State<PgPool>,
434 AuthUser(user): AuthUser,
435 ) -> Result<impl IntoResponse> {
436 user.check_not_suspended()?;
437 db::users::disconnect_user_stripe(&db, user.id).await?;
438 Ok(StatusCode::NO_CONTENT)
439 }
440
441 /// Resend the email verification link to the authenticated user.
442 #[tracing::instrument(skip_all, name = "users::resend_verification")]
443 pub(in crate::routes::api) async fn resend_verification(
444 State(db): State<PgPool>,
445 State(config): State<Config>,
446 State(email): State<EmailClient>,
447 headers: HeaderMap,
448 AuthUser(user): AuthUser,
449 ) -> Result<Response> {
450 let is_htmx = is_htmx_request(&headers);
451
452 // Get full user data
453 let db_user = db::users::get_user_by_id(&db, user.id)
454 .await?
455 .ok_or(AppError::NotFound)?;
456
457 // Check if already verified
458 if db_user.email_verified {
459 if is_htmx {
460 return Ok(AlertTemplate::new("info", "Email already verified").into_response());
461 }
462 return Ok(Json(SuccessMessageResponse {
463 success: true,
464 message: "Email already verified",
465 })
466 .into_response());
467 }
468
469 let verify_url = email::generate_verification_url(
470 &config.host_url,
471 user.id,
472 &db_user.email,
473 &config.signing_secret,
474 );
475
476 // Send verification email
477 if let Err(e) = email
478 .send_verification(&db_user.email, db_user.display_name.as_deref(), &verify_url)
479 .await
480 {
481 if is_htmx {
482 tracing::error!(error = ?e, "failed to send verification email");
483 return Ok(AlertTemplate::new(
484 "error",
485 "Failed to send verification email. Please try again.",
486 )
487 .into_response());
488 }
489 return Err(e);
490 }
491
492 tracing::info!(user_id = %user.id, "verification email sent");
493
494 if is_htmx {
495 return Ok(
496 AlertTemplate::new("success", "Verification email sent. Check your inbox.")
497 .into_response(),
498 );
499 }
500
501 Ok(Json(SuccessMessageResponse {
502 success: true,
503 message: "Verification email sent",
504 })
505 .into_response())
506 }
507
508 /// Form input for requesting account deletion (requires username confirmation).
509 #[derive(Debug, Deserialize)]
510 pub(crate) struct RequestDeletionForm {
511 pub username: String,
512 }
513
514 /// Send an account deletion confirmation email after verifying the username.
515 #[tracing::instrument(skip_all, name = "users::request_account_deletion")]
516 pub(in crate::routes::api) async fn request_account_deletion(
517 State(db): State<PgPool>,
518 State(config): State<Config>,
519 State(email): State<EmailClient>,
520 headers: HeaderMap,
521 AuthUser(user): AuthUser,
522 ValidatedForm(form): ValidatedForm<RequestDeletionForm>,
523 ) -> Result<Response> {
524 user.check_not_sandbox()?;
525 let is_htmx = is_htmx_request(&headers);
526
527 // Get user from DB
528 let db_user = db::users::get_user_by_id(&db, user.id)
529 .await?
530 .ok_or(AppError::NotFound)?;
531
532 // Verify username matches (case-insensitive)
533 if form.username.to_lowercase() != db_user.username.to_lowercase() {
534 if is_htmx {
535 return Ok(Html(
536 FormStatusTemplate {
537 success: false,
538 message: "Username does not match".to_string(),
539 }
540 .render_string()?,
541 )
542 .into_response());
543 }
544 return Err(AppError::BadRequest("Username does not match".to_string()));
545 }
546
547 let delete_url = email::generate_deletion_url(
548 &config.host_url,
549 user.id,
550 &db_user.email,
551 &config.signing_secret,
552 );
553
554 // Send deletion confirmation email
555 if let Err(e) = email
556 .send_deletion_confirmation(&db_user.email, db_user.display_name.as_deref(), &delete_url)
557 .await
558 {
559 if is_htmx {
560 tracing::error!(error = ?e, "failed to send deletion email");
561 return Ok(Html(
562 FormStatusTemplate {
563 success: false,
564 message: "Failed to send email. Please try again.".to_string(),
565 }
566 .render_string()?,
567 )
568 .into_response());
569 }
570 return Err(e);
571 }
572
573 tracing::info!(user_id = %user.id, "deletion confirmation email sent");
574
575 if is_htmx {
576 return Ok(Html(
577 FormStatusTemplate {
578 success: true,
579 message: "Confirmation email sent. Check your inbox.".to_string(),
580 }
581 .render_string()?,
582 )
583 .into_response());
584 }
585
586 Ok(Json(SuccessMessageResponse {
587 success: true,
588 message: "Deletion confirmation email sent",
589 })
590 .into_response())
591 }
592
593 /// Form input for submitting a suspension appeal.
594 #[derive(Debug, Deserialize)]
595 pub(crate) struct AppealForm {
596 pub appeal_text: String,
597 }
598
599 /// Submit an appeal for a suspended account.
600 #[tracing::instrument(skip_all, name = "users::submit_appeal")]
601 pub(in crate::routes::api) async fn submit_appeal(
602 State(db): State<PgPool>,
603 headers: HeaderMap,
604 AuthUser(user): AuthUser,
605 ValidatedForm(form): ValidatedForm<AppealForm>,
606 ) -> Result<Response> {
607 let is_htmx = is_htmx_request(&headers);
608
609 // Must be suspended to appeal
610 if !user.suspended {
611 if is_htmx {
612 return Ok(AlertTemplate::new("info", "Your account is not suspended.").into_response());
613 }
614 return Err(AppError::BadRequest("Account is not suspended".to_string()));
615 }
616
617 // Reject re-submission if a recent denial exists (within 30 days)
618 let db_user = db::users::get_user_by_id(&db, user.id)
619 .await?
620 .ok_or(AppError::NotFound)?;
621 if db_user.appeal_decision.as_deref() == Some("denied")
622 && let Some(decided_at) = db_user.appeal_decided_at
623 {
624 let days_since = (chrono::Utc::now() - decided_at).num_days();
625 if days_since < 30 {
626 let msg = format!(
627 "Your appeal was denied. You may resubmit after {} days.",
628 30 - days_since
629 );
630 if is_htmx {
631 return Ok(AlertTemplate::new("error", &msg).into_response());
632 }
633 return Err(AppError::BadRequest(msg));
634 }
635 }
636 // Also reject if an appeal is already pending
637 if db_user.appeal_submitted_at.is_some() && db_user.appeal_decision.is_none() {
638 if is_htmx {
639 return Ok(
640 AlertTemplate::new("info", "You already have a pending appeal.").into_response(),
641 );
642 }
643 return Err(AppError::BadRequest("Appeal already pending".to_string()));
644 }
645
646 let appeal_text = form.appeal_text.trim();
647 if appeal_text.is_empty() || appeal_text.len() > 2000 {
648 if is_htmx {
649 return Ok(AlertTemplate::new(
650 "error",
651 "Appeal must be between 1 and 2000 characters.",
652 )
653 .into_response());
654 }
655 return Err(AppError::validation(
656 "Appeal must be between 1 and 2000 characters".to_string(),
657 ));
658 }
659
660 db::users::submit_appeal(&db, user.id, appeal_text).await?;
661
662 tracing::info!(user_id = %user.id, "suspension appeal submitted");
663
664 if is_htmx {
665 return Ok(AlertTemplate::new(
666 "success",
667 "Appeal submitted. We'll review it as soon as possible.",
668 )
669 .into_response());
670 }
671
672 Ok(StatusCode::NO_CONTENT.into_response())
673 }
674