Skip to main content

max / makenotwork

18.0 KB · 503 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
150 let new_hash = crate::auth::hash_password(&req.new_password)?;
151 db::users::update_user_password(&state.db, user.id, &new_hash).await?;
152
153 // Invalidate all other sessions so stolen/leaked sessions can't survive a password change
154 let current_tracking_id = session
155 .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY)
156 .await
157 .ok()
158 .flatten();
159 if let Some(current_id) = current_tracking_id {
160 let revoked_ids = db::sessions::delete_other_sessions(&state.db, current_id, user.id).await?;
161 for id in &revoked_ids {
162 state.session_cache.remove(id);
163 }
164 if !revoked_ids.is_empty() {
165 tracing::info!(user_id = %user.id, revoked = revoked_ids.len(), event = "password_change_revoke_sessions", "Revoked other sessions on password change");
166 }
167 }
168
169 // Rotate session ID so old session cookie is invalidated
170 session.cycle_id().await
171 .context("session cycle")?;
172
173 if is_htmx {
174 return Ok(Html(SaveStatusTemplate {
175 success: true,
176 message: "Password updated".to_string(),
177 }.render_string()).into_response());
178 }
179
180 Ok(StatusCode::NO_CONTENT.into_response())
181 }
182
183 /// Permanently delete the authenticated user's account.
184 /// If the creator has completed sales, content is kept accessible for 90 days
185 /// so buyers can download their purchased files before removal.
186 #[tracing::instrument(skip_all, name = "users::delete_account")]
187 pub(in crate::routes::api) async fn delete_account(
188 State(state): State<AppState>,
189 AuthUser(user): AuthUser,
190 ) -> Result<impl IntoResponse> {
191 user.check_not_sandbox()?;
192
193 if db::users::has_completed_sales(&state.db, user.id).await? {
194 db::users::schedule_content_removal(&state.db, user.id).await?;
195 tracing::info!(user_id = %user.id, "creator account deletion scheduled with 90-day content grace period");
196
197 // Notify historical buyers (capped + Postmark-throttled). Fire-and-forget.
198 let pool = state.db.clone();
199 let email = state.email.clone();
200 let creator_name = user.display_name.clone()
201 .unwrap_or_else(|| user.username.to_string());
202 let user_id = user.id;
203 tokio::spawn(async move {
204 crate::email::send_creator_departure_notifications(&pool, &email, user_id, creator_name).await;
205 });
206 } else {
207 db::users::delete_user(&state.db, user.id).await?;
208 }
209
210 Ok(StatusCode::NO_CONTENT)
211 }
212
213 /// Self-deactivate account (enter limbo state).
214 #[tracing::instrument(skip_all, name = "users::deactivate_account")]
215 pub(in crate::routes::api) async fn deactivate_account(
216 State(state): State<AppState>,
217 AuthUser(user): AuthUser,
218 ) -> Result<impl IntoResponse> {
219 user.check_not_sandbox()?;
220 db::users::deactivate_user(&state.db, user.id).await?;
221 tracing::info!(user_id = %user.id, "user self-deactivated account");
222 Ok(StatusCode::NO_CONTENT)
223 }
224
225 /// Reactivate a self-deactivated account.
226 #[tracing::instrument(skip_all, name = "users::reactivate_account")]
227 pub(in crate::routes::api) async fn reactivate_account(
228 State(state): State<AppState>,
229 AuthUser(user): AuthUser,
230 ) -> Result<impl IntoResponse> {
231 db::users::reactivate_user(&state.db, user.id).await?;
232 tracing::info!(user_id = %user.id, "user reactivated account");
233 Ok(StatusCode::NO_CONTENT)
234 }
235
236 /// Voluntarily pause creator account. Cancels the creator tier subscription,
237 /// sets `cancel_at_period_end` on all active fan subscriptions (graceful expiry),
238 /// and blocks new purchases. Content remains hosted indefinitely.
239 #[tracing::instrument(skip_all, name = "users::pause_creator")]
240 pub(in crate::routes::api) async fn pause_creator(
241 State(state): State<AppState>,
242 AuthUser(user): AuthUser,
243 ) -> Result<impl IntoResponse> {
244 user.check_not_sandbox()?;
245
246 let db_user = db::users::get_user_by_id(&state.db, user.id)
247 .await?
248 .ok_or(AppError::NotFound)?;
249
250 if db_user.is_suspended() {
251 return Err(AppError::BadRequest("Cannot pause a suspended account".to_string()));
252 }
253 if db_user.is_deactivated() {
254 return Err(AppError::BadRequest("Cannot pause a deactivated account".to_string()));
255 }
256 if db_user.is_creator_paused() {
257 return Err(AppError::BadRequest("Account is already paused".to_string()));
258 }
259 if !db_user.can_create_projects {
260 return Err(AppError::BadRequest("Only creators can pause their account".to_string()));
261 }
262
263 if let Some(ref stripe) = state.stripe {
264 // Cancel the creator's own tier subscription on Stripe (platform-level)
265 if let Some(ct_sub) = db::creator_tiers::get_creator_sub_by_user(&state.db, user.id).await?
266 && ct_sub.status == db::SubscriptionStatus::Active
267 && let Err(e) = stripe.cancel_platform_subscription(&ct_sub.stripe_subscription_id).await
268 {
269 tracing::warn!(error = ?e, "failed to cancel creator tier subscription on Stripe during pause");
270 }
271
272 // Set cancel_at_period_end on all active fan subscriptions (connected account)
273 if let Some(ref stripe_account_id) = db_user.stripe_account_id {
274 let fan_subs = db::subscriptions::get_active_subscriptions_by_creator(&state.db, user.id).await?;
275 for sub in &fan_subs {
276 if let Err(e) = stripe.set_cancel_at_period_end(
277 &sub.stripe_subscription_id,
278 stripe_account_id,
279 true,
280 ).await {
281 tracing::warn!(
282 stripe_sub_id = %sub.stripe_subscription_id,
283 error = ?e,
284 "failed to set cancel_at_period_end on fan subscription during pause"
285 );
286 }
287 }
288 }
289 }
290
291 // Set the pause timestamp
292 db::users::pause_creator(&state.db, user.id).await?;
293 tracing::info!(user_id = %user.id, "creator paused account");
294
295 Ok(StatusCode::NO_CONTENT)
296 }
297
298 /// Disconnect the authenticated user's Stripe account.
299 #[tracing::instrument(skip_all, name = "users::disconnect_stripe")]
300 pub(in crate::routes::api) async fn disconnect_stripe(
301 State(state): State<AppState>,
302 AuthUser(user): AuthUser,
303 ) -> Result<impl IntoResponse> {
304 user.check_not_suspended()?;
305 db::users::disconnect_user_stripe(&state.db, user.id).await?;
306 Ok(StatusCode::NO_CONTENT)
307 }
308
309 /// Resend the email verification link to the authenticated user.
310 #[tracing::instrument(skip_all, name = "users::resend_verification")]
311 pub(in crate::routes::api) async fn resend_verification(
312 State(state): State<AppState>,
313 headers: HeaderMap,
314 AuthUser(user): AuthUser,
315 ) -> Result<Response> {
316 let is_htmx = is_htmx_request(&headers);
317
318 // Get full user data
319 let db_user = db::users::get_user_by_id(&state.db, user.id)
320 .await?
321 .ok_or(AppError::NotFound)?;
322
323 // Check if already verified
324 if db_user.email_verified {
325 if is_htmx {
326 return Ok(AlertTemplate::new("info", "Email already verified").into_response());
327 }
328 return Ok(Json(SuccessMessageResponse {
329 success: true,
330 message: "Email already verified",
331 }).into_response());
332 }
333
334 // Generate verification URL
335 let verify_url = email::generate_verification_url(
336 &state.config.host_url,
337 user.id,
338 &db_user.email,
339 &state.config.signing_secret,
340 );
341
342 // Send verification email
343 if let Err(e) = state.email
344 .send_verification(&db_user.email, db_user.display_name.as_deref(), &verify_url)
345 .await
346 {
347 if is_htmx {
348 tracing::error!(error = ?e, "failed to send verification email");
349 return Ok(AlertTemplate::new("error", "Failed to send verification email. Please try again.").into_response());
350 }
351 return Err(e);
352 }
353
354 tracing::info!(user_id = %user.id, "verification email sent");
355
356 if is_htmx {
357 return Ok(AlertTemplate::new("success", "Verification email sent. Check your inbox.").into_response());
358 }
359
360 Ok(Json(SuccessMessageResponse {
361 success: true,
362 message: "Verification email sent",
363 }).into_response())
364 }
365
366 /// Form input for requesting account deletion (requires username confirmation).
367 #[derive(Debug, Deserialize)]
368 pub struct RequestDeletionForm {
369 pub username: String,
370 }
371
372 /// Send an account deletion confirmation email after verifying the username.
373 #[tracing::instrument(skip_all, name = "users::request_account_deletion")]
374 pub(in crate::routes::api) async fn request_account_deletion(
375 State(state): State<AppState>,
376 headers: HeaderMap,
377 AuthUser(user): AuthUser,
378 Form(form): Form<RequestDeletionForm>,
379 ) -> Result<Response> {
380 user.check_not_sandbox()?;
381 let is_htmx = is_htmx_request(&headers);
382
383 // Get user from DB
384 let db_user = db::users::get_user_by_id(&state.db, user.id)
385 .await?
386 .ok_or(AppError::NotFound)?;
387
388 // Verify username matches (case-insensitive)
389 if form.username.to_lowercase() != db_user.username.to_lowercase() {
390 if is_htmx {
391 return Ok(Html(FormStatusTemplate {
392 success: false,
393 message: "Username does not match".to_string(),
394 }.render_string()).into_response());
395 }
396 return Err(AppError::BadRequest("Username does not match".to_string()));
397 }
398
399 // Generate deletion URL
400 let delete_url = email::generate_deletion_url(
401 &state.config.host_url,
402 user.id,
403 &db_user.email,
404 &state.config.signing_secret,
405 );
406
407 // Send deletion confirmation email
408 if let Err(e) = state.email
409 .send_deletion_confirmation(&db_user.email, db_user.display_name.as_deref(), &delete_url)
410 .await
411 {
412 if is_htmx {
413 tracing::error!(error = ?e, "failed to send deletion email");
414 return Ok(Html(FormStatusTemplate {
415 success: false,
416 message: "Failed to send email. Please try again.".to_string(),
417 }.render_string()).into_response());
418 }
419 return Err(e);
420 }
421
422 tracing::info!(user_id = %user.id, "deletion confirmation email sent");
423
424 if is_htmx {
425 return Ok(Html(FormStatusTemplate {
426 success: true,
427 message: "Confirmation email sent. Check your inbox.".to_string(),
428 }.render_string()).into_response());
429 }
430
431 Ok(Json(SuccessMessageResponse {
432 success: true,
433 message: "Deletion confirmation email sent",
434 }).into_response())
435 }
436
437 /// Form input for submitting a suspension appeal.
438 #[derive(Debug, Deserialize)]
439 pub struct AppealForm {
440 pub appeal_text: String,
441 }
442
443 /// Submit an appeal for a suspended account.
444 #[tracing::instrument(skip_all, name = "users::submit_appeal")]
445 pub(in crate::routes::api) async fn submit_appeal(
446 State(state): State<AppState>,
447 headers: HeaderMap,
448 AuthUser(user): AuthUser,
449 Form(form): Form<AppealForm>,
450 ) -> Result<Response> {
451 let is_htmx = is_htmx_request(&headers);
452
453 // Must be suspended to appeal
454 if !user.suspended {
455 if is_htmx {
456 return Ok(AlertTemplate::new("info", "Your account is not suspended.").into_response());
457 }
458 return Err(AppError::BadRequest("Account is not suspended".to_string()));
459 }
460
461 // Reject re-submission if a recent denial exists (within 30 days)
462 let db_user = db::users::get_user_by_id(&state.db, user.id)
463 .await?
464 .ok_or(AppError::NotFound)?;
465 if db_user.appeal_decision.as_deref() == Some("denied")
466 && let Some(decided_at) = db_user.appeal_decided_at
467 {
468 let days_since = (chrono::Utc::now() - decided_at).num_days();
469 if days_since < 30 {
470 let msg = format!("Your appeal was denied. You may resubmit after {} days.", 30 - days_since);
471 if is_htmx {
472 return Ok(AlertTemplate::new("error", &msg).into_response());
473 }
474 return Err(AppError::BadRequest(msg));
475 }
476 }
477 // Also reject if an appeal is already pending
478 if db_user.appeal_submitted_at.is_some() && db_user.appeal_decision.is_none() {
479 if is_htmx {
480 return Ok(AlertTemplate::new("info", "You already have a pending appeal.").into_response());
481 }
482 return Err(AppError::BadRequest("Appeal already pending".to_string()));
483 }
484
485 let appeal_text = form.appeal_text.trim();
486 if appeal_text.is_empty() || appeal_text.len() > 2000 {
487 if is_htmx {
488 return Ok(AlertTemplate::new("error", "Appeal must be between 1 and 2000 characters.").into_response());
489 }
490 return Err(AppError::validation("Appeal must be between 1 and 2000 characters".to_string()));
491 }
492
493 db::users::submit_appeal(&state.db, user.id, appeal_text).await?;
494
495 tracing::info!(user_id = %user.id, "suspension appeal submitted");
496
497 if is_htmx {
498 return Ok(AlertTemplate::new("success", "Appeal submitted. We'll review it as soon as possible.").into_response());
499 }
500
501 Ok(StatusCode::NO_CONTENT.into_response())
502 }
503