Skip to main content

max / makenotwork

14.0 KB · 393 lines History Blame Raw
1 //! Account deletion confirmation and unsubscribe handlers.
2
3 use axum::{
4 Form,
5 extract::{Query, State},
6 response::{IntoResponse, Response},
7 };
8 use serde::Deserialize;
9 use sqlx::PgPool;
10 use tower_sessions::Session;
11
12 use crate::{
13 config::Config,
14 db::{self, UserId},
15 email,
16 error::{AppError, Result},
17 helpers::{constant_time_compare, get_csrf_token},
18 templates::{AccountDeletedTemplate, ConfirmDeleteTemplate, EmailResultTemplate},
19 };
20
21 /// Query parameters for the signed account deletion confirmation link.
22 #[derive(Debug, Deserialize)]
23 pub(super) struct ConfirmDeleteQuery {
24 pub user: String,
25 pub expires: String,
26 pub sig: String,
27 }
28
29 /// Form input for the POST account deletion confirmation.
30 #[derive(Debug, Deserialize)]
31 pub(super) struct ConfirmDeleteForm {
32 pub user: String,
33 pub expires: String,
34 pub sig: String,
35 }
36
37 /// Validate the deletion link parameters and return parsed values, or an error
38 /// response if the link is expired or the signature is invalid.
39 async fn validate_deletion_link(
40 db: &PgPool,
41 config: &Config,
42 user_str: &str,
43 expires_str: &str,
44 sig: &str,
45 ) -> Result<std::result::Result<UserId, Response>> {
46 let user_id: UserId = match user_str.parse() {
47 Ok(id) => id,
48 Err(_) => {
49 return Ok(Err(EmailResultTemplate {
50 csrf_token: None,
51 title: "Invalid Link".to_string(),
52 message: "This deletion link is invalid.".to_string(),
53 link_url: "/dashboard".to_string(),
54 link_text: "Go to dashboard".to_string(),
55 }
56 .into_response()));
57 }
58 };
59
60 // Parse expiry
61 let expires: i64 = match expires_str.parse() {
62 Ok(e) => e,
63 Err(_) => {
64 return Ok(Err(EmailResultTemplate {
65 csrf_token: None,
66 title: "Invalid Link".to_string(),
67 message: "This deletion link is invalid.".to_string(),
68 link_url: "/dashboard".to_string(),
69 link_text: "Go to dashboard".to_string(),
70 }
71 .into_response()));
72 }
73 };
74
75 // Check if link has expired
76 let now = chrono::Utc::now().timestamp();
77 if now > expires {
78 return Ok(Err(EmailResultTemplate {
79 csrf_token: None,
80 title: "Link Expired".to_string(),
81 message: "This deletion link has expired. Please request a new one from your account settings.".to_string(),
82 link_url: "/dashboard".to_string(),
83 link_text: "Go to dashboard".to_string(),
84 }
85 .into_response()));
86 }
87
88 // Get user to verify they exist and get their email for signature verification
89 let Some(user) = db::users::get_user_by_id(db, user_id).await? else {
90 return Ok(Err(EmailResultTemplate {
91 csrf_token: None,
92 title: "Invalid Link".to_string(),
93 message: "This deletion link is invalid.".to_string(),
94 link_url: "/dashboard".to_string(),
95 link_text: "Go to dashboard".to_string(),
96 }
97 .into_response()));
98 };
99
100 // Verify signature
101 let expected_sig =
102 email::generate_deletion_signature(&config.signing_secret, user_id, expires, &user.email);
103 if !constant_time_compare(sig, &expected_sig) {
104 return Ok(Err(EmailResultTemplate {
105 csrf_token: None,
106 title: "Invalid Link".to_string(),
107 message: "This deletion link is invalid.".to_string(),
108 link_url: "/dashboard".to_string(),
109 link_text: "Go to dashboard".to_string(),
110 }
111 .into_response()));
112 }
113
114 Ok(Ok(user_id))
115 }
116
117 /// Show the account deletion confirmation page (GET).
118 ///
119 /// Validates the signed link from the email, then renders a confirmation page
120 /// with a POST form so that link prefetching by browsers and email clients
121 /// cannot accidentally trigger the deletion.
122 #[tracing::instrument(skip_all, name = "email_actions::confirm_delete_page")]
123 pub(super) async fn confirm_delete_page(
124 State(db): State<PgPool>,
125 State(config): State<Config>,
126 session: Session,
127 Query(query): Query<ConfirmDeleteQuery>,
128 ) -> Result<Response> {
129 // Validate the deletion link parameters
130 match validate_deletion_link(&db, &config, &query.user, &query.expires, &query.sig).await? {
131 Err(error_response) => Ok(error_response),
132 Ok(_user_id) => {
133 let csrf_token = get_csrf_token(&session).await;
134 Ok(ConfirmDeleteTemplate {
135 csrf_token,
136 user: query.user,
137 expires: query.expires,
138 sig: query.sig,
139 }
140 .into_response())
141 }
142 }
143 }
144
145 /// Perform the actual account deletion (POST).
146 ///
147 /// Re-validates the signed link parameters from the form body, then deletes
148 /// the account and destroys the session.
149 #[tracing::instrument(skip_all, name = "email_actions::confirm_delete_handler")]
150 pub(super) async fn confirm_delete_handler(
151 State(db): State<PgPool>,
152 State(config): State<Config>,
153 State(mailer): State<crate::email::EmailClient>,
154 State(caches): State<crate::AppCaches>,
155 session: Session,
156 Form(form): Form<ConfirmDeleteForm>,
157 ) -> Result<Response> {
158 // Validate the deletion link parameters
159 let user_id =
160 match validate_deletion_link(&db, &config, &form.user, &form.expires, &form.sig).await? {
161 Err(error_response) => return Ok(error_response),
162 Ok(id) => id,
163 };
164
165 // If creator has sales, schedule 90-day content grace period instead of immediate deletion
166 if db::users::has_completed_sales(&db, user_id).await? {
167 db::users::schedule_content_removal(&db, user_id).await?;
168 tracing::info!(user_id = %user_id, event = "account_deletion_scheduled", "Creator account scheduled for removal with 90-day content grace period");
169
170 // Notify all buyers that this creator is leaving (fire-and-forget)
171 let pool = db.clone();
172 let email_client = mailer.clone();
173 tokio::spawn(async move {
174 let creator_name = match db::users::get_user_by_id(&pool, user_id).await {
175 Ok(Some(u)) => u.display_name.unwrap_or_else(|| u.username.to_string()),
176 _ => "A creator".to_string(),
177 };
178 crate::email::send_creator_departure_notifications(
179 &pool,
180 &email_client,
181 user_id,
182 creator_name,
183 )
184 .await;
185 });
186 } else {
187 crate::delete_user_account(&db, &caches, user_id).await?;
188 tracing::info!(user_id = %user_id, event = "account_deleted", "Account permanently deleted via confirmed POST");
189 }
190
191 // Destroy session
192 let _ = session.flush().await;
193
194 Ok(AccountDeletedTemplate { csrf_token: None }.into_response())
195 }
196
197 // Unsubscribe
198
199 /// Query parameters for unsubscribe links.
200 #[derive(Debug, Deserialize)]
201 pub(super) struct UnsubscribeQuery {
202 pub user: Option<String>,
203 /// Email-keyed variant for imported subscribers with no MNW account
204 /// (mutually exclusive with `user`).
205 pub email: Option<String>,
206 pub action: Option<String>,
207 pub target: Option<String>,
208 pub sig: Option<String>,
209 }
210
211 /// Show the unsubscribe confirmation page (GET).
212 ///
213 /// Verifies the signature and performs the unsubscribe action immediately.
214 /// This handles clicks from email body links.
215 #[tracing::instrument(skip_all, name = "email_actions::unsubscribe_page")]
216 pub(super) async fn unsubscribe_page(
217 State(db): State<PgPool>,
218 State(config): State<Config>,
219 Query(query): Query<UnsubscribeQuery>,
220 ) -> Result<Response> {
221 let result = |title: &str, msg: &str| -> Response {
222 EmailResultTemplate {
223 csrf_token: None,
224 title: title.to_string(),
225 message: msg.to_string(),
226 link_url: "/dashboard".to_string(),
227 link_text: "Go to dashboard".to_string(),
228 }
229 .into_response()
230 };
231
232 match dispatch_unsubscribe(&db, &config, &query).await? {
233 Some(message) => Ok(result("Unsubscribed", &message)),
234 None => Ok(result("Invalid Link", "This unsubscribe link is invalid.")),
235 }
236 }
237
238 /// Handle RFC 8058 one-click unsubscribe (POST).
239 ///
240 /// Email clients (Gmail, Apple Mail, etc.) send a POST with
241 /// `List-Unsubscribe=One-Click` in the body. CSRF is exempted for this path.
242 #[tracing::instrument(skip_all, name = "email_actions::unsubscribe_handler")]
243 pub(super) async fn unsubscribe_handler(
244 State(db): State<PgPool>,
245 State(config): State<Config>,
246 Query(query): Query<UnsubscribeQuery>,
247 ) -> Result<Response> {
248 match dispatch_unsubscribe(&db, &config, &query).await? {
249 Some(_) => Ok(axum::http::StatusCode::OK.into_response()),
250 None => Err(AppError::BadRequest("Invalid unsubscribe link".to_string())),
251 }
252 }
253
254 /// Verify an unsubscribe query (user-keyed or email-keyed) and perform it.
255 /// Returns `Some(message)` on success, `None` if the link is invalid/unverified.
256 /// The email-keyed form serves imported subscribers who have no MNW account.
257 async fn dispatch_unsubscribe(
258 db: &PgPool,
259 config: &Config,
260 query: &UnsubscribeQuery,
261 ) -> Result<Option<String>> {
262 let (Some(action_str), Some(target), Some(sig)) = (&query.action, &query.target, &query.sig)
263 else {
264 return Ok(None);
265 };
266 let Ok(action) = action_str.parse::<email::UnsubscribeAction>() else {
267 return Ok(None);
268 };
269
270 // Email-keyed (imported, no MNW account) takes precedence when present.
271 if let Some(email_addr) = &query.email {
272 if !email::verify_email_unsubscribe_signature(
273 email_addr,
274 action,
275 target,
276 sig,
277 &config.signing_secret,
278 ) {
279 return Ok(None);
280 }
281 return Ok(Some(
282 perform_email_unsubscribe(db, email_addr, action, target).await?,
283 ));
284 }
285
286 let Some(user_str) = &query.user else {
287 return Ok(None);
288 };
289 let Ok(user_id) = user_str.parse::<UserId>() else {
290 return Ok(None);
291 };
292 if !email::verify_unsubscribe_signature(user_id, action, target, sig, &config.signing_secret) {
293 return Ok(None);
294 }
295 Ok(Some(
296 perform_unsubscribe(db, user_id, action, target).await?,
297 ))
298 }
299
300 /// Perform an email-keyed unsubscribe (imported subscriber, no MNW account).
301 /// Only mailing-list unsubscribe is meaningful without an account, the
302 /// preference/follow actions all key on a user.
303 async fn perform_email_unsubscribe(
304 db: &PgPool,
305 email_addr: &str,
306 action: email::UnsubscribeAction,
307 target: &str,
308 ) -> Result<String> {
309 match action {
310 email::UnsubscribeAction::MailingList => {
311 let list_id: db::MailingListId = target
312 .parse()
313 .map_err(|_| AppError::BadRequest("Invalid mailing list ID".to_string()))?;
314 db::mailing_lists::unsubscribe_by_email(db, list_id, email_addr).await?;
315 Ok("You have been unsubscribed from this mailing list.".to_string())
316 }
317 _ => Err(AppError::BadRequest(
318 "This unsubscribe link is invalid.".to_string(),
319 )),
320 }
321 }
322
323 /// Execute the unsubscribe action and return a human-readable message.
324 async fn perform_unsubscribe(
325 db: &PgPool,
326 user_id: UserId,
327 action: email::UnsubscribeAction,
328 target: &str,
329 ) -> Result<String> {
330 use email::UnsubscribeAction;
331 match action {
332 UnsubscribeAction::Broadcast => {
333 // Unfollow the creator
334 let target_id: UserId = target
335 .parse()
336 .map_err(|_| AppError::BadRequest("Invalid target".to_string()))?;
337 db::follows::unfollow(db, user_id, db::FollowTargetType::User, target_id.into())
338 .await?;
339 Ok(
340 "You have unfollowed this creator and will no longer receive their broadcasts."
341 .to_string(),
342 )
343 }
344 UnsubscribeAction::Release => {
345 db::users::disable_notification(db, user_id, "notify_release").await?;
346 Ok(
347 "You will no longer receive emails about new releases from creators you follow."
348 .to_string(),
349 )
350 }
351 UnsubscribeAction::Sale => {
352 db::users::disable_notification(db, user_id, "notify_sale").await?;
353 Ok(
354 "You will no longer receive email notifications when someone buys your content."
355 .to_string(),
356 )
357 }
358 UnsubscribeAction::Follower => {
359 db::users::disable_notification(db, user_id, "notify_follower").await?;
360 Ok("You will no longer receive email notifications for new followers.".to_string())
361 }
362 UnsubscribeAction::Login => {
363 db::users::disable_notification(db, user_id, "login_notification_enabled").await?;
364 Ok(
365 "You will no longer receive email notifications for new device sign-ins."
366 .to_string(),
367 )
368 }
369 UnsubscribeAction::Issue => {
370 db::users::disable_notification(db, user_id, "notify_issues").await?;
371 Ok(
372 "You will no longer receive email notifications for issues on your repositories."
373 .to_string(),
374 )
375 }
376 UnsubscribeAction::Status => {
377 db::users::disable_notification(db, user_id, "notify_status").await?;
378 Ok("You will no longer receive platform status notifications.".to_string())
379 }
380 UnsubscribeAction::MailingList => {
381 let list_id: db::MailingListId = target
382 .parse()
383 .map_err(|_| AppError::BadRequest("Invalid mailing list ID".to_string()))?;
384 db::mailing_lists::unsubscribe(db, list_id, user_id).await?;
385 Ok("You have been unsubscribed from this mailing list.".to_string())
386 }
387 UnsubscribeAction::NotifyTip => {
388 db::users::disable_notification(db, user_id, "notify_tip").await?;
389 Ok("You will no longer receive email notifications for tips.".to_string())
390 }
391 }
392 }
393