Skip to main content

max / makenotwork

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