Skip to main content

max / makenotwork

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