Skip to main content

max / makenotwork

21.6 KB · 577 lines History Blame Raw
1 //! Account deletion confirmation and unsubscribe handlers.
2
3 use crate::extractors::ValidatedQuery;
4 use axum::{
5 Form,
6 extract::State,
7 response::{IntoResponse, Response},
8 };
9 use serde::Deserialize;
10 use sqlx::PgPool;
11 use tower_sessions::Session;
12
13 use crate::{
14 config::Config,
15 db::{self, UserId},
16 email,
17 error::{AppError, Result},
18 helpers::{constant_time_compare, get_csrf_token},
19 templates::{AccountDeletedTemplate, ConfirmDeleteTemplate, EmailResultTemplate},
20 };
21
22 /// Query parameters for the signed account deletion confirmation link.
23 #[derive(Debug, Deserialize)]
24 pub(super) struct ConfirmDeleteQuery {
25 pub user: String,
26 pub expires: String,
27 pub sig: String,
28 }
29
30 /// Form input for the POST account deletion confirmation.
31 #[derive(Debug, Deserialize)]
32 pub(super) struct ConfirmDeleteForm {
33 pub user: String,
34 pub expires: String,
35 pub sig: String,
36 }
37
38 /// Validate the deletion link parameters and return parsed values, or an error
39 /// response if the link is expired or the signature is invalid.
40 async fn validate_deletion_link(
41 db: &PgPool,
42 config: &Config,
43 user_str: &str,
44 expires_str: &str,
45 sig: &str,
46 ) -> Result<std::result::Result<UserId, Response>> {
47 let user_id: UserId = match user_str.parse() {
48 Ok(id) => id,
49 Err(_) => {
50 return Ok(Err(EmailResultTemplate {
51 csrf_token: None,
52 title: "Invalid Link".to_string(),
53 message: "This deletion link is invalid.".to_string(),
54 link_url: "/dashboard".to_string(),
55 link_text: "Go to dashboard".to_string(),
56 }
57 .into_response()));
58 }
59 };
60
61 // Parse expiry
62 let expires: i64 = match expires_str.parse() {
63 Ok(e) => e,
64 Err(_) => {
65 return Ok(Err(EmailResultTemplate {
66 csrf_token: None,
67 title: "Invalid Link".to_string(),
68 message: "This deletion link is invalid.".to_string(),
69 link_url: "/dashboard".to_string(),
70 link_text: "Go to dashboard".to_string(),
71 }
72 .into_response()));
73 }
74 };
75
76 // Check if link has expired
77 let now = chrono::Utc::now().timestamp();
78 if now > expires {
79 return Ok(Err(EmailResultTemplate {
80 csrf_token: None,
81 title: "Link Expired".to_string(),
82 message: "This deletion link has expired. Please request a new one from your account settings.".to_string(),
83 link_url: "/dashboard".to_string(),
84 link_text: "Go to dashboard".to_string(),
85 }
86 .into_response()));
87 }
88
89 // Get user to verify they exist and get their email for signature verification
90 let Some(user) = db::users::get_user_by_id(db, user_id).await? else {
91 return Ok(Err(EmailResultTemplate {
92 csrf_token: None,
93 title: "Invalid Link".to_string(),
94 message: "This deletion link is invalid.".to_string(),
95 link_url: "/dashboard".to_string(),
96 link_text: "Go to dashboard".to_string(),
97 }
98 .into_response()));
99 };
100
101 // Verify signature
102 let expected_sig =
103 email::generate_deletion_signature(&config.signing_secret, user_id, expires, &user.email);
104 if !constant_time_compare(sig, &expected_sig) {
105 return Ok(Err(EmailResultTemplate {
106 csrf_token: None,
107 title: "Invalid Link".to_string(),
108 message: "This deletion link is invalid.".to_string(),
109 link_url: "/dashboard".to_string(),
110 link_text: "Go to dashboard".to_string(),
111 }
112 .into_response()));
113 }
114
115 Ok(Ok(user_id))
116 }
117
118 /// Show the account deletion confirmation page (GET).
119 ///
120 /// Validates the signed link from the email, then renders a confirmation page
121 /// with a POST form so that link prefetching by browsers and email clients
122 /// cannot accidentally trigger the deletion.
123 #[tracing::instrument(skip_all, name = "email_actions::confirm_delete_page")]
124 pub(super) async fn confirm_delete_page(
125 State(db): State<PgPool>,
126 State(config): State<Config>,
127 session: Session,
128 ValidatedQuery(query): ValidatedQuery<ConfirmDeleteQuery>,
129 ) -> Result<Response> {
130 // Validate the deletion link parameters
131 match validate_deletion_link(&db, &config, &query.user, &query.expires, &query.sig).await? {
132 Err(error_response) => Ok(error_response),
133 Ok(_user_id) => {
134 let csrf_token = get_csrf_token(&session).await;
135 Ok(ConfirmDeleteTemplate {
136 csrf_token,
137 user: query.user,
138 expires: query.expires,
139 sig: query.sig,
140 }
141 .into_response())
142 }
143 }
144 }
145
146 /// Perform the actual account deletion (POST).
147 ///
148 /// Re-validates the signed link parameters from the form body, then deletes
149 /// the account and destroys the session.
150 #[tracing::instrument(skip_all, name = "email_actions::confirm_delete_handler")]
151 pub(super) async fn confirm_delete_handler(
152 State(db): State<PgPool>,
153 State(config): State<Config>,
154 State(mailer): State<crate::email::EmailClient>,
155 State(caches): State<crate::AppCaches>,
156 session: Session,
157 Form(form): Form<ConfirmDeleteForm>,
158 ) -> Result<Response> {
159 // Validate the deletion link parameters
160 let user_id =
161 match validate_deletion_link(&db, &config, &form.user, &form.expires, &form.sig).await? {
162 Err(error_response) => return Ok(error_response),
163 Ok(id) => id,
164 };
165
166 // If creator has sales, schedule 90-day content grace period instead of immediate deletion
167 if db::users::has_completed_sales(&db, user_id).await? {
168 db::users::schedule_content_removal(&db, user_id).await?;
169 tracing::info!(user_id = %user_id, event = "account_deletion_scheduled", "Creator account scheduled for removal with 90-day content grace period");
170
171 // Notify all buyers that this creator is leaving (fire-and-forget)
172 let pool = db.clone();
173 let email_client = mailer.clone();
174 tokio::spawn(async move {
175 let creator_name = match db::users::get_user_by_id(&pool, user_id).await {
176 Ok(Some(u)) => u.display_name.unwrap_or_else(|| u.username.to_string()),
177 _ => "A creator".to_string(),
178 };
179 crate::email::send_creator_departure_notifications(
180 &pool,
181 &email_client,
182 user_id,
183 creator_name,
184 )
185 .await;
186 });
187 } else {
188 crate::delete_user_account(&db, &caches, user_id).await?;
189 tracing::info!(user_id = %user_id, event = "account_deleted", "Account permanently deleted via confirmed POST");
190 }
191
192 // Destroy session
193 let _ = session.flush().await;
194
195 Ok(AccountDeletedTemplate { csrf_token: None }.into_response())
196 }
197
198 // Unsubscribe
199
200 /// Query parameters for unsubscribe links.
201 #[derive(Debug, Deserialize)]
202 pub(super) struct UnsubscribeQuery {
203 pub user: Option<String>,
204 /// Email-keyed variant for imported subscribers with no MNW account
205 /// (mutually exclusive with `user`).
206 pub email: Option<String>,
207 pub action: Option<String>,
208 pub target: Option<String>,
209 pub sig: Option<String>,
210 /// Subscription-keyed variant over the unified tables. Takes precedence
211 /// over both older forms: it identifies one subscription exactly, which is
212 /// what one-click needs, and through it the subscriber, which is what the
213 /// preferences page needs.
214 pub sub: Option<String>,
215 }
216
217 /// Show the unsubscribe confirmation page (GET).
218 ///
219 /// Verifies the signature and performs the unsubscribe action immediately.
220 /// This handles clicks from email body links.
221 #[tracing::instrument(skip_all, name = "email_actions::unsubscribe_page")]
222 pub(super) async fn unsubscribe_page(
223 State(db): State<PgPool>,
224 State(config): State<Config>,
225 ValidatedQuery(query): ValidatedQuery<UnsubscribeQuery>,
226 ) -> Result<Response> {
227 let result = |title: &str, msg: &str| -> Response {
228 EmailResultTemplate {
229 csrf_token: None,
230 title: title.to_string(),
231 message: msg.to_string(),
232 link_url: "/dashboard".to_string(),
233 link_text: "Go to dashboard".to_string(),
234 }
235 .into_response()
236 };
237
238 // A subscription-keyed link opens the preferences page. The older forms
239 // unsubscribe on GET, which they should not, but changing that would break
240 // links already sitting in inboxes; they retire with their senders.
241 if let Some(sub) = &query.sub
242 && let Some(sig) = &query.sig
243 && let Some(page) = preferences_page(&db, &config, sub, sig).await?
244 {
245 return Ok(page);
246 }
247
248 match dispatch_unsubscribe(&db, &config, &query).await? {
249 Some(message) => Ok(result("Unsubscribed", &message)),
250 None => Ok(result("Invalid Link", "This unsubscribe link is invalid.")),
251 }
252 }
253
254 /// Handle RFC 8058 one-click unsubscribe (POST).
255 ///
256 /// Email clients (Gmail, Apple Mail, etc.) send a POST with
257 /// `List-Unsubscribe=One-Click` in the body. CSRF is exempted for this path.
258 #[tracing::instrument(skip_all, name = "email_actions::unsubscribe_handler")]
259 pub(super) async fn unsubscribe_handler(
260 State(db): State<PgPool>,
261 State(config): State<Config>,
262 ValidatedQuery(query): ValidatedQuery<UnsubscribeQuery>,
263 ) -> Result<Response> {
264 // One-click, per RFC 8058: unsubscribe exactly the list the mail came from,
265 // no confirmation step and no page. Required lists are refused, since
266 // nothing should have sent a one-click header for one.
267 if let Some(sub) = &query.sub
268 && let Some(sig) = &query.sig
269 {
270 let Some(subscription_id) = verified_subscription(sub, sig, &config) else {
271 return Err(AppError::BadRequest("Invalid unsubscribe link".to_string()));
272 };
273 if db::lists::subscription_is_required(&db, subscription_id).await? {
274 return Err(AppError::BadRequest(
275 "This list cannot be unsubscribed from.".to_string(),
276 ));
277 }
278 // Idempotent: a retried one-click reports success rather than failing.
279 db::lists::unsubscribe(&db, subscription_id, db::ConsentEvent::OptOut).await?;
280 return Ok(axum::http::StatusCode::OK.into_response());
281 }
282
283 match dispatch_unsubscribe(&db, &config, &query).await? {
284 Some(_) => Ok(axum::http::StatusCode::OK.into_response()),
285 None => Err(AppError::BadRequest("Invalid unsubscribe link".to_string())),
286 }
287 }
288
289 /// Form body for the preferences-page actions. The signed token travels with
290 /// every form, because it is the authorisation and there is no session.
291 #[derive(Debug, Deserialize)]
292 pub(super) struct PreferencesForm {
293 pub sub: String,
294 pub sig: String,
295 /// The subscription the row acts on. Verified to belong to the same
296 /// subscriber as `sub`, so a valid token cannot be aimed at a stranger.
297 pub target: Option<String>,
298 pub action: Option<String>,
299 }
300
301 /// Parse and verify a subscription-keyed token.
302 fn verified_subscription(sub: &str, sig: &str, config: &Config) -> Option<db::ListSubscriptionId> {
303 let id: uuid::Uuid = sub.parse().ok()?;
304 email::verify_subscription_unsubscribe_signature(id, sig, &config.signing_secret)
305 .then(|| db::ListSubscriptionId::from_uuid(id))
306 }
307
308 /// Render the email preferences page.
309 ///
310 /// GET only ever renders. A mail client or link scanner prefetching the URL
311 /// must not unsubscribe anybody, so every mutation on the page is a POST.
312 #[tracing::instrument(skip_all, name = "email_actions::preferences_page")]
313 async fn preferences_page(
314 db: &PgPool,
315 config: &Config,
316 sub: &str,
317 sig: &str,
318 ) -> Result<Option<Response>> {
319 let Some(subscription_id) = verified_subscription(sub, sig, config) else {
320 return Ok(None);
321 };
322
323 let subscriptions = db::lists::subscriptions_for_peer(db, subscription_id).await?;
324
325 Ok(Some(
326 crate::templates::EmailPreferencesTemplate {
327 csrf_token: None,
328 token: sub.to_string(),
329 signature: sig.to_string(),
330 origin_subscription: subscription_id,
331 subscriptions,
332 }
333 .into_response(),
334 ))
335 }
336
337 /// POST /unsubscribe/list: toggle one list from the preferences page.
338 #[tracing::instrument(skip_all, name = "email_actions::preferences_toggle")]
339 pub(super) async fn preferences_toggle(
340 State(db): State<PgPool>,
341 State(config): State<Config>,
342 Form(form): Form<PreferencesForm>,
343 ) -> Result<Response> {
344 let Some(peer) = verified_subscription(&form.sub, &form.sig, &config) else {
345 return Err(AppError::BadRequest("Invalid link".to_string()));
346 };
347 let Some(target) = form
348 .target
349 .as_deref()
350 .and_then(|t| t.parse::<uuid::Uuid>().ok())
351 else {
352 return Err(AppError::BadRequest("Invalid target".to_string()));
353 };
354 let target = db::ListSubscriptionId::from_uuid(target);
355
356 // The token authorises one subscriber, not one subscription. Every row it
357 // may act on is a row that came back for that subscriber, so checking
358 // membership here is what stops a valid token being retargeted.
359 let rows = db::lists::subscriptions_for_peer(&db, peer).await?;
360 let Some(row) = rows.iter().find(|r| r.subscription_id == target) else {
361 return Err(AppError::BadRequest("Invalid target".to_string()));
362 };
363 if row.required {
364 return Err(AppError::BadRequest(
365 "This list cannot be unsubscribed from.".to_string(),
366 ));
367 }
368
369 let enabling = form.action.as_deref() == Some("resubscribe");
370 if enabling {
371 db::lists::resubscribe(&db, target).await?;
372 } else {
373 db::lists::unsubscribe(&db, target, db::ConsentEvent::OptOut).await?;
374 }
375
376 // Back to the page, so the change is visible where it was made.
377 Ok(
378 axum::response::Redirect::to(&format!("/unsubscribe?sub={}&sig={}", form.sub, form.sig))
379 .into_response(),
380 )
381 }
382
383 /// POST /unsubscribe/all: leave every list that may be left.
384 #[tracing::instrument(skip_all, name = "email_actions::preferences_unsubscribe_all")]
385 pub(super) async fn preferences_unsubscribe_all(
386 State(db): State<PgPool>,
387 State(config): State<Config>,
388 Form(form): Form<PreferencesForm>,
389 ) -> Result<Response> {
390 let Some(peer) = verified_subscription(&form.sub, &form.sig, &config) else {
391 return Err(AppError::BadRequest("Invalid link".to_string()));
392 };
393 let moved = db::lists::unsubscribe_peer_from_all(&db, peer).await?;
394
395 Ok(EmailResultTemplate {
396 csrf_token: None,
397 title: "Unsubscribed".to_string(),
398 message: format!(
399 "You have been unsubscribed from {moved} list{}. You will still receive \
400 receipts and security notices, which are not marketing.",
401 if moved == 1 { "" } else { "s" }
402 ),
403 link_url: "/".to_string(),
404 link_text: "Back to Makenotwork".to_string(),
405 }
406 .into_response())
407 }
408
409 /// Verify an unsubscribe query (user-keyed or email-keyed) and perform it.
410 /// Returns `Some(message)` on success, `None` if the link is invalid/unverified.
411 /// The email-keyed form serves imported subscribers who have no MNW account.
412 async fn dispatch_unsubscribe(
413 db: &PgPool,
414 config: &Config,
415 query: &UnsubscribeQuery,
416 ) -> Result<Option<String>> {
417 let (Some(action_str), Some(target), Some(sig)) = (&query.action, &query.target, &query.sig)
418 else {
419 return Ok(None);
420 };
421 let Ok(action) = action_str.parse::<email::UnsubscribeAction>() else {
422 return Ok(None);
423 };
424
425 // Email-keyed (imported, no MNW account) takes precedence when present.
426 if let Some(email_addr) = &query.email {
427 if !email::verify_email_unsubscribe_signature(
428 email_addr,
429 action,
430 target,
431 sig,
432 &config.signing_secret,
433 ) {
434 return Ok(None);
435 }
436 return Ok(Some(
437 perform_email_unsubscribe(db, email_addr, action, target).await?,
438 ));
439 }
440
441 let Some(user_str) = &query.user else {
442 return Ok(None);
443 };
444 let Ok(user_id) = user_str.parse::<UserId>() else {
445 return Ok(None);
446 };
447 if !email::verify_unsubscribe_signature(user_id, action, target, sig, &config.signing_secret) {
448 return Ok(None);
449 }
450 Ok(Some(
451 perform_unsubscribe(db, user_id, action, target).await?,
452 ))
453 }
454
455 /// Perform an email-keyed unsubscribe (imported subscriber, no MNW account).
456 /// Only mailing-list unsubscribe is meaningful without an account, the
457 /// preference/follow actions all key on a user.
458 async fn perform_email_unsubscribe(
459 db: &PgPool,
460 email_addr: &str,
461 action: email::UnsubscribeAction,
462 target: &str,
463 ) -> Result<String> {
464 match action {
465 email::UnsubscribeAction::MailingList => {
466 let list_id: db::MailingListId = target
467 .parse()
468 .map_err(|_| AppError::BadRequest("Invalid mailing list ID".to_string()))?;
469 db::mailing_lists::unsubscribe_by_email(db, list_id, email_addr).await?;
470 Ok("You have been unsubscribed from this mailing list.".to_string())
471 }
472 // The landing "notify me" list. `target` is unused: there is one such
473 // list, so the address alone identifies the subscription. It is still
474 // signed over, which keeps this token from being replayed as any other
475 // action for the same address.
476 //
477 // An address that was already unsubscribed reports the same message
478 // rather than an error. The person asked not to be mailed and is not
479 // being mailed; telling them the link failed would be both untrue and
480 // alarming, and one-click POSTs get retried.
481 email::UnsubscribeAction::Signup => {
482 db::email_signups::unsubscribe_email_signup(db, email_addr).await?;
483 Ok("You will not receive further updates from Makenotwork.".to_string())
484 }
485 _ => Err(AppError::BadRequest(
486 "This unsubscribe link is invalid.".to_string(),
487 )),
488 }
489 }
490
491 /// Execute the unsubscribe action and return a human-readable message.
492 async fn perform_unsubscribe(
493 db: &PgPool,
494 user_id: UserId,
495 action: email::UnsubscribeAction,
496 target: &str,
497 ) -> Result<String> {
498 use email::UnsubscribeAction;
499 match action {
500 UnsubscribeAction::Broadcast => {
501 // Unfollow the creator
502 let target_id: UserId = target
503 .parse()
504 .map_err(|_| AppError::BadRequest("Invalid target".to_string()))?;
505 db::follows::unfollow(db, user_id, db::FollowTargetType::User, target_id.into())
506 .await?;
507 Ok(
508 "You have unfollowed this creator and will no longer receive their broadcasts."
509 .to_string(),
510 )
511 }
512 UnsubscribeAction::Release => {
513 db::users::disable_notification(db, user_id, "notify_release").await?;
514 Ok(
515 "You will no longer receive emails about new releases from creators you follow."
516 .to_string(),
517 )
518 }
519 UnsubscribeAction::Sale => {
520 db::users::disable_notification(db, user_id, "notify_sale").await?;
521 Ok(
522 "You will no longer receive email notifications when someone buys your content."
523 .to_string(),
524 )
525 }
526 UnsubscribeAction::Follower => {
527 db::users::disable_notification(db, user_id, "notify_follower").await?;
528 Ok("You will no longer receive email notifications for new followers.".to_string())
529 }
530 UnsubscribeAction::Login => {
531 db::users::disable_notification(db, user_id, "login_notification_enabled").await?;
532 Ok(
533 "You will no longer receive email notifications for new device sign-ins."
534 .to_string(),
535 )
536 }
537 UnsubscribeAction::Issue => {
538 db::users::disable_notification(db, user_id, "notify_issues").await?;
539 Ok(
540 "You will no longer receive email notifications for issues on your repositories."
541 .to_string(),
542 )
543 }
544 UnsubscribeAction::Status => {
545 db::users::disable_notification(db, user_id, "notify_status").await?;
546 Ok("You will no longer receive platform status notifications.".to_string())
547 }
548 UnsubscribeAction::MailingList => {
549 let list_id: db::MailingListId = target
550 .parse()
551 .map_err(|_| AppError::BadRequest("Invalid mailing list ID".to_string()))?;
552 db::mailing_lists::unsubscribe(db, list_id, user_id).await?;
553 Ok("You have been unsubscribed from this mailing list.".to_string())
554 }
555 UnsubscribeAction::NotifyTip => {
556 db::users::disable_notification(db, user_id, "notify_tip").await?;
557 Ok("You will no longer receive email notifications for tips.".to_string())
558 }
559 UnsubscribeAction::Invite => {
560 db::users::disable_notification(db, user_id, "invite").await?;
561 Ok(
562 "You will no longer receive email notifications when one of your invite \
563 codes is used."
564 .to_string(),
565 )
566 }
567 // Signup is keyed on the address, not the account: the landing list
568 // holds addresses that mostly have no user behind them, and an account
569 // sharing an address with a signup row is a coincidence rather than a
570 // link. Honouring a user-keyed token here would unsubscribe by an
571 // association we never established.
572 UnsubscribeAction::Signup => Err(AppError::BadRequest(
573 "This unsubscribe link is invalid.".to_string(),
574 )),
575 }
576 }
577