Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'worktree-agent-ac5b181a013e54348' | 1 | //! A person's email addresses: adding, confirming, choosing the primary and |
| 2 | //! the backup, removing, keeping them private, and finding whose an address | |
| 3 | //! is. | |
| 4 | //! | |
| 5 | //! `user_emails` holds every address. `users.primary_email_id` names the | |
| 6 | //! primary, and `users.email` and `users.email_verified_at` are kept as a | |
| 7 | //! copy of it (other code reads them, and "the account is confirmed" means | |
| 8 | //! its primary is). Every change to the primary here writes all three. | |
| 9 | //! | |
| 10 | //! A confirmed address belongs to one account: a unique index on confirmed | |
| 11 | //! rows makes sure of it. Unconfirmed rows may repeat across accounts; the | |
| 12 | //! first account to follow its confirmation link keeps the address, in one | |
| 13 | //! transaction that confirms its row only if nobody else's is confirmed, | |
| 14 | //! and then drops everyone else's unconfirmed row for it. An account whose | |
| 15 | //! primary was dropped that way (it never confirmed it) is left without one | |
| 16 | //! until it confirms another address, which becomes primary on its own. | |
| 17 | //! | |
| 18 | //! Sensitive changes (adding, removing, primary, backup) need proof that it | |
| 19 | //! is the person (`security.rs`), are written to their security log, are | |
| 20 | //! announced as `user.email_*` events, and are told to every confirmed | |
| 21 | //! address, the removed one included. | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 22 | //! |
| 23 | //! A confirmation email carries a six-digit code and a link, either one | |
| 24 | //! enough. Both live in one `email_tokens` row, bound to the account and | |
| 25 | //! the address: the link's token as its id (a SHA-256), the code as an | |
| 26 | //! HMAC under IDENTITY_KEY ([`crypto::code_hash`]). Using either deletes | |
| 27 | //! the row, so the other stops working too, and sending another email for | |
| 28 | //! the address deletes the rows before it. Both work for | |
| 29 | //! [`CONFIRM_TTL_SECONDS`]. Wrong codes are throttled per account and per | |
| 30 | //! client (throttle.rs). | |
| 31 | //! | |
| 32 | //! An account with no confirmed primary is pending: the site, the API and | |
| 33 | //! git let it do nothing but confirm its address, change it, or sign out. | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 34 | //! The invite it signed up with was spent then, and what it gives is |
| 35 | //! applied in the same transaction that confirms the address (invites.rs, | |
| 36 | //! `apply_invite_statements`): the workspace it names becomes a workspace | |
| 37 | //! invitation the person accepts or declines (invitations.rs). | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 38 | |
| 39 | use std::collections::HashMap; | |
| 40 | ||
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 41 | use g1t_contracts::account_deletion::{GHOST_ID, GHOST_USERNAME}; |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 42 | use g1t_contracts::accounts::*; |
| 43 | use g1t_contracts::events::UserEmailChanged; | |
| 44 | use g1t_contracts::identity::{UserArgs, UsernameArgs}; | |
| 45 | use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after}; | |
| 46 | use g1t_contracts::{FailureCode, Outcome, new_id}; | |
| 47 | use g1t_kit::now_ms; | |
| 48 | use serde::Deserialize; | |
| 49 | use worker::Result; | |
| 50 | use worker::wasm_bindgen::JsValue; | |
| 51 | ||
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 52 | use crate::email::Confirming; |
| 53 | use crate::invites::AwaitingJoin; | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 54 | use crate::security::{PEOPLE_ONLY, is_person}; |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 55 | use crate::throttle::{self, CODE_ACCOUNT, CODE_CLIENT, CODE_THROTTLED, CONFIRM_ACCOUNT}; |
| 56 | use crate::{Identity, crypto, email}; | |
| 57 | ||
| 58 | /// The one answer to a code that does not confirm anything: wrong, used, | |
| 59 | /// expired, or for an address no longer on the account. | |
| 60 | pub const WRONG_CODE: &str = "That code is not right, or it has expired. Check the latest email from g1t, or send a new code."; | |
| 61 | ||
| 62 | /// The answer when a confirmation email was sent less than a minute ago. | |
| 63 | pub const SENT_RECENTLY: &str = "We sent an email less than a minute ago. Check your inbox, then try again."; | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 64 | |
| 65 | /// One row of `user_emails`. | |
| 66 | #[derive(Clone, Debug, Deserialize)] | |
| 67 | pub struct EmailRow { | |
| 68 | pub id: String, | |
| 69 | pub email: String, | |
| 70 | pub display: String, | |
| 71 | pub verified_at: Option<String>, | |
| 72 | pub sent_at: Option<String>, | |
| 73 | pub created_at: String, | |
| 74 | } | |
| 75 | ||
| 76 | /// What `users` says about a person's addresses. | |
| 77 | #[derive(Debug, Deserialize)] | |
| 78 | struct AccountRow { | |
| 79 | id: String, | |
| 80 | username: String, | |
| 81 | primary_email_id: Option<String>, | |
| 82 | backup_email_id: Option<String>, | |
| 83 | private_email: u8, | |
| 84 | block_private_pushes: u8, | |
| 85 | #[serde(default)] | |
| 86 | created_at: String, | |
| 87 | } | |
| 88 | ||
| 89 | const ACCOUNT_COLUMNS: &str = | |
| 90 | "id, username, primary_email_id, backup_email_id, private_email, block_private_pushes, created_at"; | |
| 91 | ||
| 92 | /// The order addresses are shown in: the primary, confirmed ones, the rest; | |
| 93 | /// oldest first within each. | |
| 94 | fn sorted(mut rows: Vec<EmailRow>, primary: Option<&str>) -> Vec<EmailRow> { | |
| 95 | rows.sort_by(|a, b| { | |
| 96 | let rank = |row: &EmailRow| (Some(row.id.as_str()) != primary, row.verified_at.is_none()); | |
| 97 | rank(a).cmp(&rank(b)).then_with(|| a.created_at.cmp(&b.created_at)).then_with(|| a.id.cmp(&b.id)) | |
| 98 | }); | |
| 99 | rows | |
| 100 | } | |
| 101 | ||
| 102 | fn states(rows: &[EmailRow], primary: Option<&str>) -> Vec<EmailState> { | |
| 103 | rows.iter() | |
| 104 | .map(|row| EmailState { | |
| 105 | email: row.email.clone(), | |
| 106 | verified: row.verified_at.is_some(), | |
| 107 | primary: Some(row.id.as_str()) == primary, | |
| 108 | }) | |
| 109 | .collect() | |
| 110 | } | |
| 111 | ||
| 112 | /// The address commits g1t makes for a person carry: their noreply address | |
| 113 | /// while they keep their address private or have no confirmed primary. | |
| 114 | fn commit_email(private: bool, primary: Option<&EmailRow>, noreply: &str) -> String { | |
| 115 | match primary { | |
| 116 | Some(row) if !private && row.verified_at.is_some() => row.email.clone(), | |
| 117 | _ => noreply.to_owned(), | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | fn view(account: &AccountRow, rows: Vec<EmailRow>) -> AccountEmails { | |
| 122 | let primary = account.primary_email_id.as_deref(); | |
| 123 | let rows = sorted(rows, primary); | |
| 124 | let noreply = noreply_address(&account.id, &account.username); | |
| 125 | let private = account.private_email != 0; | |
| 126 | let commit = commit_email(private, rows.iter().find(|row| Some(row.id.as_str()) == primary), &noreply); | |
| 127 | AccountEmails { | |
| 128 | emails: rows | |
| 129 | .into_iter() | |
| 130 | .map(|row| AccountEmail { | |
| 131 | primary: Some(row.id.as_str()) == primary, | |
| 132 | backup: Some(row.id.as_str()) == account.backup_email_id.as_deref(), | |
| 133 | verified: row.verified_at.is_some(), | |
| 134 | email: row.display, | |
| 135 | created_at: row.created_at, | |
| 136 | verified_at: row.verified_at, | |
| 137 | }) | |
| 138 | .collect(), | |
| 139 | private_email: private, | |
| 140 | block_private_pushes: account.block_private_pushes != 0, | |
| 141 | noreply, | |
| 142 | commit_email: commit, | |
| 143 | limit: MAX_EMAILS as u32, | |
| 144 | } | |
| 145 | } | |
| 146 | ||
| 147 | /// Whether pushes by this person are checked for their addresses: only | |
| 148 | /// while the address is private and they asked for pushes to be blocked. | |
| 149 | fn guards_pushes(account: &AccountRow) -> bool { | |
| 150 | account.private_email != 0 && account.block_private_pushes != 0 | |
| 151 | } | |
| 152 | ||
| 153 | /// Whether a confirmation link may be sent again to an address last sent | |
| 154 | /// one at `sent_at`, at `now_ms`. | |
| 155 | pub fn may_resend(sent_at: Option<&str>, now_ms: u64) -> bool { | |
| 156 | !is_recent(sent_at, now_ms, RESEND_SECONDS) | |
| 157 | } | |
| 158 | ||
| 159 | impl Identity { | |
| 160 | async fn account_row(&self, user_id: &str) -> Result<Option<AccountRow>> { | |
| 161 | self.db | |
| 162 | .prepare(format!("SELECT {ACCOUNT_COLUMNS} FROM users WHERE id = ?")) | |
| 163 | .bind(&[user_id.into()])? | |
| 164 | .first::<AccountRow>(None) | |
| 165 | .await | |
| 166 | } | |
| 167 | ||
| 168 | pub async fn email_rows(&self, user_id: &str) -> Result<Vec<EmailRow>> { | |
| 169 | self.db | |
| 170 | .prepare( | |
| 171 | "SELECT id, email, display, verified_at, sent_at, created_at FROM user_emails | |
| 172 | WHERE user_id = ? ORDER BY created_at, id", | |
| 173 | ) | |
| 174 | .bind(&[user_id.into()])? | |
| 175 | .all() | |
| 176 | .await? | |
| 177 | .results::<EmailRow>() | |
| 178 | } | |
| 179 | ||
| 180 | async fn emails_view(&self, user_id: &str) -> Result<Outcome<AccountEmails>> { | |
| 181 | let Some(account) = self.account_row(user_id).await? else { | |
| 182 | return Ok(Outcome::fail(FailureCode::NotFound, "No such account.")); | |
| 183 | }; | |
| 184 | let rows = self.email_rows(user_id).await?; | |
| 185 | Ok(Outcome::Ok(view(&account, rows))) | |
| 186 | } | |
| 187 | ||
| 188 | /// A person's confirmed addresses, lowercased, the primary first. | |
| 189 | pub async fn verified_emails(&self, user_id: &str) -> Result<Vec<String>> { | |
| 190 | let account = self.account_row(user_id).await?; | |
| 191 | let primary = account.as_ref().and_then(|account| account.primary_email_id.as_deref()); | |
| 192 | Ok(sorted(self.email_rows(user_id).await?, primary) | |
| 193 | .into_iter() | |
| 194 | .filter(|row| row.verified_at.is_some()) | |
| 195 | .map(|row| row.email) | |
| 196 | .collect()) | |
| 197 | } | |
| 198 | ||
| 199 | /// The account that has confirmed `email`, by id. The one helper every | |
| 200 | /// "does this address belong to someone" question goes through (signing | |
| 201 | /// in with GitHub, invites, password resets, signing in by email). | |
| 202 | pub async fn user_with_verified_email(&self, email: &str) -> Result<Option<String>> { | |
| 203 | #[derive(Deserialize)] | |
| 204 | struct Owner { | |
| 205 | user_id: String, | |
| 206 | } | |
| 207 | let Some(email) = normalize_email(email) else { | |
| 208 | return Ok(None); | |
| 209 | }; | |
| 210 | Ok(self | |
| 211 | .db | |
| 212 | .prepare("SELECT user_id FROM user_emails WHERE email = ? AND verified_at IS NOT NULL") | |
| 213 | .bind(&[email.as_str().into()])? | |
| 214 | .first::<Owner>(None) | |
| 215 | .await? | |
| 216 | .map(|owner| owner.user_id)) | |
| 217 | } | |
| 218 | ||
| 219 | /// Whether `email` is any account's: confirmed, or the unconfirmed | |
| 220 | /// primary a new account signed up with. | |
| 221 | pub async fn email_in_use(&self, email: &str) -> Result<bool> { | |
| 222 | let Some(email) = normalize_email(email) else { | |
| 223 | return Ok(false); | |
| 224 | }; | |
| 225 | let found = self | |
| 226 | .db | |
| 227 | .prepare( | |
| 228 | "SELECT e.id FROM user_emails e JOIN users u ON u.id = e.user_id | |
| 229 | WHERE e.email = ?1 AND (e.verified_at IS NOT NULL OR u.primary_email_id = e.id) LIMIT 1", | |
| 230 | ) | |
| 231 | .bind(&[email.as_str().into()])? | |
| 232 | .first::<serde_json::Value>(None) | |
| 233 | .await?; | |
| 234 | Ok(found.is_some()) | |
| 235 | } | |
| 236 | ||
| 237 | /// `list_emails`. | |
| 238 | pub async fn list_emails(&self, a: UserArgs) -> Result<Outcome<AccountEmails>> { | |
| 239 | if !is_person(&a.user) { | |
| 240 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); | |
| 241 | } | |
| 242 | self.emails_view(&a.user.id).await | |
| 243 | } | |
| 244 | ||
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 245 | /// The key confirmation codes are kept under: IDENTITY_KEY, or none in |
| 246 | /// a development setup without one. | |
| 247 | fn code_key(&self) -> Vec<u8> { | |
| 248 | self.env.secret("IDENTITY_KEY").map(|key| key.to_string().into_bytes()).unwrap_or_default() | |
| 249 | } | |
| 250 | ||
| 251 | /// Stores a new code and link for one address, ending any sent before | |
| 252 | /// for it, and emails them. | |
| 253 | async fn send_confirmation(&self, user_id: &str, username: &str, row: &EmailRow, confirming: Confirming) -> Result<()> { | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 254 | let token = crypto::random_hex(32); |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 255 | let code = crypto::random_digits(CONFIRM_CODE_DIGITS); |
| 256 | let id = crypto::sha256_hex(&token); | |
| 257 | let code_hash = crypto::code_hash(&self.code_key(), &id, &code); | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 258 | self.db |
| 259 | .batch(vec![ | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 260 | // A new email ends the code and link of the one before. |
| 261 | self.db | |
| 262 | .prepare("DELETE FROM email_tokens WHERE user_id = ? AND kind = 'verify' AND email_id = ?") | |
| 263 | .bind(&[user_id.into(), row.id.as_str().into()])?, | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 264 | self.db |
| 265 | .prepare(format!( | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 266 | "INSERT INTO email_tokens (id, user_id, kind, expires_at, email_id, code_hash) |
| 267 | VALUES (?, ?, 'verify', {}, ?, ?)", | |
| 268 | sql_after(CONFIRM_TTL_SECONDS) | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 269 | )) |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 270 | .bind(&[id.as_str().into(), user_id.into(), row.id.as_str().into(), code_hash.as_str().into()])?, |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 271 | self.db |
| 272 | .prepare(format!("UPDATE user_emails SET sent_at = {SQL_NOW} WHERE id = ?")) | |
| 273 | .bind(&[row.id.as_str().into()])?, | |
| 274 | ]) | |
| 275 | .await?; | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 276 | email::send_confirmation(&self.env, &row.display, username, confirming, &token, &code).await |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 277 | } |
| 278 | ||
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 279 | /// Sends a new account its first code and link, to its primary. |
| 280 | pub async fn send_primary_confirmation(&self, user_id: &str, username: &str) -> Result<()> { | |
| 281 | let Some(account) = self.account_row(user_id).await? else { | |
| 282 | return Ok(()); | |
| 283 | }; | |
| 284 | let rows = self.email_rows(user_id).await?; | |
| 285 | let Some(row) = rows.iter().find(|row| Some(row.id.as_str()) == account.primary_email_id.as_deref()) else { | |
| 286 | return Ok(()); | |
| 287 | }; | |
| 288 | if row.verified_at.is_some() { | |
| 289 | return Ok(()); | |
| 290 | } | |
| 291 | self.send_confirmation(user_id, username, row, Confirming::NewAccount).await | |
| 292 | } | |
| 293 | ||
| Merge branch 'worktree-agent-ac5b181a013e54348' | 294 | /// `add_email`. |
| 295 | pub async fn add_email(&self, a: AccountEmailArgs) -> Result<Outcome<AccountEmails>> { | |
| 296 | if !is_person(&a.user) { | |
| 297 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); | |
| 298 | } | |
| 299 | let typed = a.email.trim(); | |
| 300 | let Some(email) = normalize_email(typed) else { | |
| 301 | return Ok(Outcome::fail(FailureCode::Invalid, "Enter a valid email address.")); | |
| 302 | }; | |
| 303 | if parse_noreply(&email).is_some() || email.ends_with("@users.g1t.sh") { | |
| 304 | return Ok(Outcome::fail(FailureCode::Invalid, "That is a g1t noreply address; add an address you receive mail at.")); | |
| 305 | } | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 306 | if self.email_blocked(&email).await? { |
| 307 | return Ok(Outcome::fail(FailureCode::Forbidden, crate::suspension::BLOCKED)); | |
| 308 | } | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 309 | if let Some(refusal) = self.proof(&a.user.id, &a.reauth).await?.refusal() { |
| 310 | return Ok(refusal); | |
| 311 | } | |
| 312 | let rows = self.email_rows(&a.user.id).await?; | |
| 313 | if let Some(row) = rows.iter().find(|row| row.email == email) { | |
| 314 | if row.verified_at.is_some() { | |
| 315 | return Ok(Outcome::fail(FailureCode::Conflict, "That address is already on your account.")); | |
| 316 | } | |
| 317 | // Added before and not confirmed: adding it again sends the link again. | |
| 318 | if may_resend(row.sent_at.as_deref(), now_ms()) && self.allow(CONFIRM_ACCOUNT, &a.user.id).await? { | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 319 | self.send_confirmation(&a.user.id, &a.user.username, row, Confirming::AddedAddress).await?; |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 320 | } |
| 321 | return self.emails_view(&a.user.id).await; | |
| 322 | } | |
| 323 | if rows.len() >= MAX_EMAILS { | |
| 324 | return Ok(Outcome::fail( | |
| 325 | FailureCode::Invalid, | |
| 326 | format!("An account can have {MAX_EMAILS} addresses. Remove one first."), | |
| 327 | )); | |
| 328 | } | |
| 329 | if self.user_with_verified_email(&email).await?.is_some() { | |
| 330 | return Ok(Outcome::fail(FailureCode::Conflict, "That address is confirmed on another g1t account.")); | |
| 331 | } | |
| 332 | let now = now_ms(); | |
| 333 | let row = EmailRow { | |
| 334 | id: new_id("eml", now), | |
| 335 | email: email.clone(), | |
| 336 | display: typed.to_owned(), | |
| 337 | verified_at: None, | |
| 338 | sent_at: None, | |
| 339 | created_at: rfc3339(now), | |
| 340 | }; | |
| 341 | let inserted = self | |
| 342 | .db | |
| 343 | .prepare( | |
| 344 | "INSERT INTO user_emails (id, user_id, email, display, created_at) | |
| 345 | SELECT ?1, ?2, ?3, ?4, ?5 | |
| 346 | WHERE (SELECT count(*) FROM user_emails WHERE user_id = ?2) < ?6", | |
| 347 | ) | |
| 348 | .bind(&[ | |
| 349 | row.id.as_str().into(), | |
| 350 | a.user.id.as_str().into(), | |
| 351 | row.email.as_str().into(), | |
| 352 | row.display.as_str().into(), | |
| 353 | row.created_at.as_str().into(), | |
| 354 | (MAX_EMAILS as f64).into(), | |
| 355 | ])? | |
| 356 | .run() | |
| 357 | .await; | |
| 358 | if let Err(error) = inserted { | |
| 359 | // The same address added twice at once. | |
| 360 | if error.to_string().contains("UNIQUE") { | |
| 361 | return self.emails_view(&a.user.id).await; | |
| 362 | } | |
| 363 | return Err(error); | |
| 364 | } | |
| 365 | if !self.allow(CONFIRM_ACCOUNT, &a.user.id).await? { | |
| 366 | worker::console_log!("confirmation email held back: too many this hour"); | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 367 | } else if let Err(error) = self.send_confirmation(&a.user.id, &a.user.username, &row, Confirming::AddedAddress).await { |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 368 | worker::console_error!("confirmation email failed: {error}"); |
| 369 | } | |
| 370 | self.log_security(&a.user.id, "email_added", Some(&row.display), None).await; | |
| 371 | self.tell_addresses(&a.user.id, &a.user.username, &format!("{} was added", row.display), None).await; | |
| 372 | self.announce_email("user.email_added", &a.user.id, false).await; | |
| 373 | self.emails_view(&a.user.id).await | |
| 374 | } | |
| 375 | ||
| 376 | /// `resend_email_verification`. | |
| 377 | pub async fn resend_email_verification(&self, a: AccountEmailArgs) -> Result<Outcome<bool>> { | |
| 378 | if !is_person(&a.user) { | |
| 379 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); | |
| 380 | } | |
| 381 | let email = normalize_email(&a.email).unwrap_or_default(); | |
| 382 | let rows = self.email_rows(&a.user.id).await?; | |
| 383 | let Some(row) = rows.iter().find(|row| row.email == email) else { | |
| 384 | return Ok(Outcome::fail(FailureCode::NotFound, "That address is not on your account.")); | |
| 385 | }; | |
| 386 | if row.verified_at.is_some() { | |
| 387 | return Ok(Outcome::fail(FailureCode::Conflict, "That address is already confirmed.")); | |
| 388 | } | |
| 389 | if !may_resend(row.sent_at.as_deref(), now_ms()) { | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 390 | return Ok(Outcome::fail(FailureCode::Conflict, SENT_RECENTLY)); |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 391 | } |
| 392 | if !self.allow(CONFIRM_ACCOUNT, &a.user.id).await? { | |
| 393 | return Ok(Outcome::fail(FailureCode::Conflict, "Too many confirmation emails this hour. Check your inbox, or try again later.")); | |
| 394 | } | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 395 | let confirming = if self.account_confirmed(&a.user.id).await? { Confirming::AddedAddress } else { Confirming::NewAccount }; |
| 396 | self.send_confirmation(&a.user.id, &a.user.username, row, confirming).await?; | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 397 | Ok(Outcome::Ok(true)) |
| 398 | } | |
| 399 | ||
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 400 | /// Whether the account has a confirmed primary: whether it is past the |
| 401 | /// confirmation page. | |
| 402 | pub async fn account_confirmed(&self, user_id: &str) -> Result<bool> { | |
| 403 | let Some(account) = self.account_row(user_id).await? else { | |
| 404 | return Ok(false); | |
| 405 | }; | |
| 406 | Ok(self | |
| 407 | .email_rows(user_id) | |
| 408 | .await? | |
| 409 | .iter() | |
| 410 | .any(|row| Some(row.id.as_str()) == account.primary_email_id.as_deref() && row.verified_at.is_some())) | |
| 411 | } | |
| 412 | ||
| 413 | /// The confirmation page's "send a new code": a new code and link for | |
| 414 | /// the primary of an account that has not confirmed it, or for its | |
| 415 | /// oldest unconfirmed address when a confirmed account elsewhere took | |
| 416 | /// its primary. At most one a minute; the ones before stop working. | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 417 | pub async fn resend_primary(&self, user: &g1t_contracts::User) -> Result<Outcome<bool>> { |
| 418 | let Some(account) = self.account_row(&user.id).await? else { | |
| 419 | return Ok(Outcome::fail(FailureCode::NotFound, "No such account.")); | |
| 420 | }; | |
| 421 | let rows = sorted(self.email_rows(&user.id).await?, account.primary_email_id.as_deref()); | |
| 422 | if rows.iter().any(|row| Some(row.id.as_str()) == account.primary_email_id.as_deref() && row.verified_at.is_some()) { | |
| 423 | return Ok(Outcome::fail(FailureCode::Conflict, "This account's email is already confirmed.")); | |
| 424 | } | |
| 425 | let Some(row) = rows.iter().find(|row| row.verified_at.is_none()) else { | |
| 426 | return Ok(Outcome::fail(FailureCode::Conflict, "Add an email address in your settings first.")); | |
| 427 | }; | |
| 428 | if !may_resend(row.sent_at.as_deref(), now_ms()) { | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 429 | return Ok(Outcome::fail(FailureCode::Conflict, SENT_RECENTLY)); |
| 430 | } | |
| 431 | self.send_confirmation(&user.id, &account.username, row, Confirming::NewAccount).await?; | |
| 432 | Ok(Outcome::Ok(true)) | |
| 433 | } | |
| 434 | ||
| 435 | /// `change_pending_email`: the confirmation page's "wrong address?". | |
| 436 | /// Only for an account with no confirmed address: its unconfirmed | |
| 437 | /// addresses are replaced by this one, which becomes the primary and | |
| 438 | /// gets a new code and link. Needs no password: the account has nothing | |
| 439 | /// yet that one would protect, and the new address still has to be | |
| 440 | /// confirmed. Counts against the confirmation emails an hour. | |
| 441 | pub async fn change_pending_email(&self, a: PendingEmailArgs) -> Result<Outcome<AccountEmails>> { | |
| 442 | if !is_person(&a.user) { | |
| 443 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); | |
| 444 | } | |
| 445 | let typed = a.email.trim(); | |
| 446 | let Some(email) = normalize_email(typed) else { | |
| 447 | return Ok(Outcome::fail(FailureCode::Invalid, "Enter a valid email address.")); | |
| 448 | }; | |
| 449 | if parse_noreply(&email).is_some() || email.ends_with("@users.g1t.sh") { | |
| 450 | return Ok(Outcome::fail(FailureCode::Invalid, "That is a g1t noreply address; use an address you receive mail at.")); | |
| 451 | } | |
| 452 | let rows = self.email_rows(&a.user.id).await?; | |
| 453 | if rows.iter().any(|row| row.verified_at.is_some()) { | |
| 454 | return Ok(Outcome::fail( | |
| 455 | FailureCode::Conflict, | |
| 456 | "Your account has a confirmed address already. Change your addresses in your email settings.", | |
| 457 | )); | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 458 | } |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 459 | if self.user_with_verified_email(&email).await?.is_some() { |
| 460 | return Ok(Outcome::fail(FailureCode::Conflict, "That address is confirmed on another g1t account.")); | |
| 461 | } | |
| 462 | // The address it has already: nothing to change; the page's "send a | |
| 463 | // new code" sends one. | |
| 464 | if let [only] = rows.as_slice() | |
| 465 | && only.email == email | |
| 466 | { | |
| 467 | return self.emails_view(&a.user.id).await; | |
| 468 | } | |
| 469 | if !self.allow(CONFIRM_ACCOUNT, &a.user.id).await? { | |
| 470 | return Ok(Outcome::fail(FailureCode::Conflict, "Too many confirmation emails this hour. Check your inbox, or try again later.")); | |
| 471 | } | |
| 472 | let now = now_ms(); | |
| 473 | let row = EmailRow { | |
| 474 | id: new_id("eml", now), | |
| 475 | email: email.clone(), | |
| 476 | display: typed.to_owned(), | |
| 477 | verified_at: None, | |
| 478 | sent_at: None, | |
| 479 | created_at: rfc3339(now), | |
| 480 | }; | |
| 481 | let user = JsValue::from(a.user.id.as_str()); | |
| 482 | // One transaction: every unconfirmed address and its codes go, the | |
| 483 | // new one comes in as the primary. | |
| 484 | let changed = self | |
| 485 | .db | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 486 | .batch(vec![ |
| 487 | self.db | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 488 | .prepare("DELETE FROM email_tokens WHERE user_id = ? AND kind = 'verify'") |
| 489 | .bind(&[user.clone()])?, | |
| 490 | self.db | |
| 491 | .prepare("UPDATE users SET primary_email_id = NULL, backup_email_id = NULL, email = NULL, email_verified_at = NULL WHERE id = ?") | |
| 492 | .bind(&[user.clone()])?, | |
| 493 | self.db | |
| 494 | .prepare("DELETE FROM user_emails WHERE user_id = ? AND verified_at IS NULL") | |
| 495 | .bind(&[user.clone()])?, | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 496 | self.db |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 497 | .prepare("INSERT INTO user_emails (id, user_id, email, display, created_at) VALUES (?, ?, ?, ?, ?)") |
| 498 | .bind(&[ | |
| 499 | row.id.as_str().into(), | |
| 500 | user.clone(), | |
| 501 | row.email.as_str().into(), | |
| 502 | row.display.as_str().into(), | |
| 503 | row.created_at.as_str().into(), | |
| 504 | ])?, | |
| 505 | self.db | |
| 506 | .prepare("UPDATE users SET primary_email_id = ?, email = ? WHERE id = ?") | |
| 507 | .bind(&[row.id.as_str().into(), row.email.as_str().into(), user.clone()])?, | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 508 | ]) |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 509 | .await; |
| 510 | if let Err(error) = changed { | |
| 511 | if error.to_string().contains("UNIQUE") { | |
| 512 | return Ok(Outcome::fail(FailureCode::Conflict, "That address is already registered.")); | |
| 513 | } | |
| 514 | return Err(error); | |
| 515 | } | |
| 516 | self.log_security(&a.user.id, "email_changed_before_confirming", Some(&row.display), None).await; | |
| 517 | if let Err(error) = self.send_confirmation(&a.user.id, &a.user.username, &row, Confirming::NewAccount).await { | |
| 518 | worker::console_error!("confirmation email failed: {error}"); | |
| 519 | } | |
| 520 | self.announce_email("user.primary_email_changed", &a.user.id, false).await; | |
| 521 | self.emails_view(&a.user.id).await | |
| 522 | } | |
| 523 | ||
| 524 | /// `confirm_email_code`: the code from a confirmation email, typed by | |
| 525 | /// the signed-in person it was sent to. Every outstanding code of the | |
| 526 | /// account is compared, each in constant time; wrong ones are counted | |
| 527 | /// against the account and the client (throttle.rs). A right one is | |
| 528 | /// used up with its link, then confirms the address it was sent to. | |
| 529 | pub async fn confirm_email_code(&self, a: ConfirmEmailCodeArgs) -> Result<Outcome<EmailConfirmed>> { | |
| 530 | #[derive(Deserialize)] | |
| 531 | struct Sent { | |
| 532 | id: String, | |
| 533 | email_id: Option<String>, | |
| 534 | code_hash: String, | |
| 535 | expires_at: String, | |
| 536 | } | |
| 537 | if !is_person(&a.user) { | |
| 538 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); | |
| 539 | } | |
| 540 | let account_key = throttle::key(CODE_ACCOUNT, &a.user.id); | |
| 541 | let client_key = a | |
| 542 | .client | |
| 543 | .as_deref() | |
| 544 | .filter(|client| !client.trim().is_empty()) | |
| 545 | .map(|client| throttle::key(CODE_CLIENT, client)); | |
| 546 | // While either key is locked, no code is even compared. | |
| 547 | let mut locked = self.locked(&account_key).await?; | |
| 548 | if !locked && let Some(client_key) = &client_key { | |
| 549 | locked = self.locked(client_key).await?; | |
| 550 | } | |
| 551 | if locked { | |
| 552 | return Ok(Outcome::fail(FailureCode::Conflict, CODE_THROTTLED)); | |
| 553 | } | |
| 554 | let now = rfc3339(now_ms()); | |
| 555 | let sent: Vec<Sent> = match tidy_confirm_code(&a.code) { | |
| 556 | Some(_) => self | |
| 557 | .db | |
| 558 | .prepare( | |
| 559 | "SELECT id, email_id, code_hash, expires_at FROM email_tokens | |
| 560 | WHERE user_id = ? AND kind = 'verify' AND code_hash IS NOT NULL", | |
| 561 | ) | |
| 562 | .bind(&[a.user.id.as_str().into()])? | |
| 563 | .all() | |
| 564 | .await? | |
| 565 | .results::<Sent>()? | |
| 566 | .into_iter() | |
| 567 | .filter(|sent| still_works(&sent.expires_at, &now)) | |
| 568 | .collect(), | |
| 569 | None => Vec::new(), | |
| 570 | }; | |
| 571 | let code = tidy_confirm_code(&a.code).unwrap_or_default(); | |
| 572 | let key = self.code_key(); | |
| 573 | let matched = matching_code(&key, &code, sent.iter().map(|sent| (sent.id.as_str(), sent.code_hash.as_str()))) | |
| 574 | .and_then(|index| sent.get(index)); | |
| 575 | // Used once: whoever deletes the row first has it. | |
| 576 | let used = match matched { | |
| 577 | Some(sent) => self | |
| 578 | .db | |
| 579 | .prepare("DELETE FROM email_tokens WHERE id = ? AND user_id = ? RETURNING id") | |
| 580 | .bind(&[sent.id.as_str().into(), a.user.id.as_str().into()])? | |
| 581 | .first::<serde_json::Value>(None) | |
| 582 | .await? | |
| 583 | .is_some(), | |
| 584 | None => false, | |
| 585 | }; | |
| 586 | let Some(sent) = matched.filter(|_| used) else { | |
| 587 | self.count(CODE_ACCOUNT, &account_key).await?; | |
| 588 | if let Some(client_key) = &client_key { | |
| 589 | self.count(CODE_CLIENT, client_key).await?; | |
| 590 | } | |
| 591 | return Ok(Outcome::fail(FailureCode::Invalid, WRONG_CODE)); | |
| 592 | }; | |
| 593 | self.clear(&account_key).await?; | |
| 594 | // Any other code and link for the same address go too. | |
| 595 | self.db | |
| 596 | .prepare("DELETE FROM email_tokens WHERE user_id = ? AND kind = 'verify' AND email_id IS ?") | |
| 597 | .bind(&[a.user.id.as_str().into(), sent.email_id.as_deref().map_or(JsValue::NULL, Into::into)])? | |
| 598 | .run() | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 599 | .await?; |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 600 | self.confirm_address(&a.user.id, sent.email_id.as_deref()).await |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 601 | } |
| 602 | ||
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 603 | /// Confirms an address after its link was followed or its code typed: |
| 604 | /// `email_id`, or the primary for links sent before addresses had ids. | |
| 605 | /// When this confirms the account, the invite it signed up with is | |
| 606 | /// applied in the same transaction. Returns what it did, or why the | |
| 607 | /// address could not be confirmed. | |
| 608 | pub async fn confirm_address(&self, user_id: &str, email_id: Option<&str>) -> Result<Outcome<EmailConfirmed>> { | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 609 | let Some(account) = self.account_row(user_id).await? else { |
| 610 | return Ok(Outcome::fail(FailureCode::Invalid, "This confirmation link is not valid or has expired.")); | |
| 611 | }; | |
| 612 | let Some(email_id) = email_id.map(str::to_owned).or(account.primary_email_id.clone()) else { | |
| 613 | return Ok(Outcome::fail(FailureCode::Invalid, "This confirmation link is not valid or has expired.")); | |
| 614 | }; | |
| 615 | let rows = self.email_rows(user_id).await?; | |
| 616 | let Some(row) = rows.into_iter().find(|row| row.id == email_id) else { | |
| 617 | return Ok(Outcome::fail( | |
| 618 | FailureCode::Conflict, | |
| 619 | "That address was confirmed by another g1t account first, or was removed from yours.", | |
| 620 | )); | |
| 621 | }; | |
| 622 | if row.verified_at.is_some() { | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 623 | return Ok(Outcome::Ok(EmailConfirmed { |
| 624 | username: account.username, | |
| 625 | email: row.display, | |
| 626 | verified: self.account_confirmed(user_id).await?, | |
| 627 | ..EmailConfirmed::default() | |
| 628 | })); | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 629 | } |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 630 | // The invite the account signed up with, if it waits for this. |
| 631 | let awaiting = self.awaiting_invite(user_id).await?; | |
| 632 | let join = match &awaiting { | |
| 633 | Some(invite) => Some(self.awaiting_join(invite).await), | |
| 634 | None => None, | |
| 635 | }; | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 636 | let won = |sql: &str| sql.replace("{WON}", "EXISTS (SELECT 1 FROM user_emails WHERE id = ?1 AND verified_at IS NOT NULL)"); |
| 637 | let id = JsValue::from(row.id.as_str()); | |
| 638 | let user = JsValue::from(user_id); | |
| 639 | let address = JsValue::from(row.email.as_str()); | |
| 640 | // One transaction. Confirm this row only if no account has the | |
| 641 | // address confirmed; then, only if it was, drop everyone else's | |
| 642 | // claim to it, and make it this account's primary when the account | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 643 | // has no confirmed primary; then, if that confirmed the account, |
| 644 | // apply the invite it signed up with. | |
| 645 | let mut statements = vec![ | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 646 | self.db |
| 647 | .prepare(format!( | |
| 648 | "UPDATE user_emails SET verified_at = {SQL_NOW} | |
| 649 | WHERE id = ?1 AND verified_at IS NULL | |
| 650 | AND NOT EXISTS (SELECT 1 FROM user_emails WHERE email = ?2 AND verified_at IS NOT NULL)" | |
| 651 | )) | |
| 652 | .bind(&[id.clone(), address.clone()])?, | |
| 653 | self.db | |
| 654 | .prepare(won( | |
| 655 | "UPDATE users SET email = NULL, email_verified_at = NULL, primary_email_id = NULL | |
| 656 | WHERE id <> ?3 AND {WON} AND primary_email_id IN ( | |
| 657 | SELECT id FROM user_emails WHERE email = ?2 AND verified_at IS NULL AND user_id <> ?3)", | |
| 658 | )) | |
| 659 | .bind(&[id.clone(), address.clone(), user.clone()])?, | |
| 660 | self.db | |
| 661 | .prepare(won( | |
| 662 | "UPDATE users SET backup_email_id = NULL | |
| 663 | WHERE id <> ?3 AND {WON} AND backup_email_id IN ( | |
| 664 | SELECT id FROM user_emails WHERE email = ?2 AND verified_at IS NULL AND user_id <> ?3)", | |
| 665 | )) | |
| 666 | .bind(&[id.clone(), address.clone(), user.clone()])?, | |
| 667 | self.db | |
| 668 | .prepare(won( | |
| 669 | "DELETE FROM user_emails WHERE email = ?2 AND verified_at IS NULL AND user_id <> ?3 AND {WON}", | |
| 670 | )) | |
| 671 | .bind(&[id.clone(), address.clone(), user.clone()])?, | |
| 672 | self.db | |
| 673 | .prepare(won( | |
| 674 | "UPDATE users SET primary_email_id = ?1, email = ?2, | |
| 675 | email_verified_at = (SELECT verified_at FROM user_emails WHERE id = ?1) | |
| 676 | WHERE id = ?3 AND {WON} AND ( | |
| 677 | primary_email_id IS NULL OR primary_email_id = ?1 | |
| 678 | OR NOT EXISTS (SELECT 1 FROM user_emails WHERE id = users.primary_email_id AND verified_at IS NOT NULL))", | |
| 679 | )) | |
| 680 | .bind(&[id.clone(), address.clone(), user.clone()])?, | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 681 | ]; |
| 682 | if let (Some(invite), Some(join)) = (&awaiting, &join) { | |
| 683 | statements.extend(self.apply_invite_statements(user_id, invite, join)?); | |
| 684 | } | |
| 685 | self.db.batch(statements).await?; | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 686 | let confirmed = self |
| 687 | .email_rows(user_id) | |
| 688 | .await? | |
| 689 | .into_iter() | |
| 690 | .any(|current| current.id == row.id && current.verified_at.is_some()); | |
| 691 | if !confirmed { | |
| 692 | return Ok(Outcome::fail( | |
| 693 | FailureCode::Conflict, | |
| 694 | "That address was confirmed by another g1t account first. Use a different address.", | |
| 695 | )); | |
| 696 | } | |
| 697 | self.log_security(user_id, "email_verified", Some(&row.display), None).await; | |
| 698 | self.announce_email("user.email_verified", user_id, false).await; | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 699 | let verified = self.account_confirmed(user_id).await?; |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 700 | let (mut invited_to, mut invite_lapsed) = (None, None); |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 701 | if let (Some(invite), Some(join), true) = (&awaiting, &join, verified) { |
| 702 | let user = g1t_contracts::User { | |
| 703 | id: user_id.to_owned(), | |
| 704 | username: account.username.clone(), | |
| 705 | verified: true, | |
| 706 | ..g1t_contracts::User::default() | |
| 707 | }; | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 708 | invited_to = self.after_applied(invite, &user, join).await?; |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 709 | invite_lapsed = match join { |
| 710 | AwaitingJoin::Lapsed(why) => Some(why.clone()), | |
| 711 | // Revoked between the read and the transaction. | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 712 | AwaitingJoin::Invited { .. } if invited_to.is_none() => Some( |
| 713 | "Your email address is confirmed. The invite you signed up with no longer applies, so it does not invite you to a workspace." | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 714 | .to_owned(), |
| 715 | ), | |
| 716 | _ => None, | |
| 717 | }; | |
| 718 | } | |
| 719 | Ok(Outcome::Ok(EmailConfirmed { | |
| 720 | username: account.username, | |
| 721 | email: row.display, | |
| 722 | verified, | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 723 | joined: None, |
| 724 | invited_to, | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 725 | invite_lapsed, |
| 726 | })) | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 727 | } |
| 728 | ||
| 729 | /// `remove_email`. | |
| 730 | pub async fn remove_email(&self, a: AccountEmailArgs) -> Result<Outcome<AccountEmails>> { | |
| 731 | if !is_person(&a.user) { | |
| 732 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); | |
| 733 | } | |
| 734 | let email = normalize_email(&a.email).unwrap_or_default(); | |
| 735 | let Some(account) = self.account_row(&a.user.id).await? else { | |
| 736 | return Ok(Outcome::fail(FailureCode::NotFound, "No such account.")); | |
| 737 | }; | |
| 738 | let rows = self.email_rows(&a.user.id).await?; | |
| 739 | if let Some(why) = removal_refusal(&states(&rows, account.primary_email_id.as_deref()), &email) { | |
| 740 | return Ok(Outcome::fail(FailureCode::Conflict, why)); | |
| 741 | } | |
| 742 | if let Some(refusal) = self.proof(&a.user.id, &a.reauth).await?.refusal() { | |
| 743 | return Ok(refusal); | |
| 744 | } | |
| 745 | let Some(row) = rows.into_iter().find(|row| row.email == email) else { | |
| 746 | return self.emails_view(&a.user.id).await; | |
| 747 | }; | |
| 748 | self.delete_address(&a.user.id, &row).await?; | |
| 749 | self.log_security(&a.user.id, "email_removed", Some(&row.display), None).await; | |
| 750 | let removed = row.verified_at.is_some().then_some(row.display.as_str()); | |
| 751 | self.tell_addresses(&a.user.id, &a.user.username, &format!("{} was removed", row.display), removed).await; | |
| 752 | self.announce_email("user.email_removed", &a.user.id, false).await; | |
| 753 | self.emails_view(&a.user.id).await | |
| 754 | } | |
| 755 | ||
| 756 | /// Deletes an address and anything pointing at it. The caller has made | |
| 757 | /// sure it is not the primary, or has moved the primary already. | |
| 758 | async fn delete_address(&self, user_id: &str, row: &EmailRow) -> Result<()> { | |
| 759 | self.db | |
| 760 | .batch(vec![ | |
| 761 | self.db | |
| 762 | .prepare("UPDATE users SET backup_email_id = NULL WHERE id = ? AND backup_email_id = ?") | |
| 763 | .bind(&[user_id.into(), row.id.as_str().into()])?, | |
| 764 | self.db | |
| 765 | .prepare("DELETE FROM email_tokens WHERE user_id = ? AND email_id = ?") | |
| 766 | .bind(&[user_id.into(), row.id.as_str().into()])?, | |
| 767 | self.db | |
| 768 | .prepare("DELETE FROM user_emails WHERE id = ? AND user_id = ?") | |
| 769 | .bind(&[row.id.as_str().into(), user_id.into()])?, | |
| 770 | ]) | |
| 771 | .await?; | |
| 772 | Ok(()) | |
| 773 | } | |
| 774 | ||
| 775 | /// Makes a confirmed address the primary, keeping `users` in step. | |
| 776 | async fn set_primary(&self, user_id: &str, row: &EmailRow) -> Result<()> { | |
| 777 | self.db | |
| 778 | .prepare( | |
| 779 | "UPDATE users SET primary_email_id = ?1, email = ?2, | |
| 780 | email_verified_at = (SELECT verified_at FROM user_emails WHERE id = ?1), | |
| 781 | backup_email_id = CASE WHEN backup_email_id = ?1 THEN NULL ELSE backup_email_id END | |
| 782 | WHERE id = ?3 AND EXISTS (SELECT 1 FROM user_emails WHERE id = ?1 AND user_id = ?3 AND verified_at IS NOT NULL)", | |
| 783 | ) | |
| 784 | .bind(&[row.id.as_str().into(), row.email.as_str().into(), user_id.into()])? | |
| 785 | .run() | |
| 786 | .await?; | |
| 787 | Ok(()) | |
| 788 | } | |
| 789 | ||
| 790 | /// `update_email_settings`. | |
| 791 | pub async fn update_email_settings(&self, a: EmailSettingsArgs) -> Result<Outcome<AccountEmails>> { | |
| 792 | if !is_person(&a.user) { | |
| 793 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); | |
| 794 | } | |
| 795 | let Some(account) = self.account_row(&a.user.id).await? else { | |
| 796 | return Ok(Outcome::fail(FailureCode::NotFound, "No such account.")); | |
| 797 | }; | |
| 798 | let rows = self.email_rows(&a.user.id).await?; | |
| 799 | let find = |email: &str| { | |
| 800 | let email = normalize_email(email).unwrap_or_default(); | |
| 801 | rows.iter().find(|row| row.email == email).cloned() | |
| 802 | }; | |
| 803 | let primary = match a.primary.as_deref().map(str::trim).filter(|email| !email.is_empty()) { | |
| 804 | None => None, | |
| 805 | Some(email) => { | |
| 806 | let normalized = normalize_email(email).unwrap_or_default(); | |
| 807 | if let Some(why) = primary_refusal(&states(&rows, account.primary_email_id.as_deref()), &normalized) { | |
| 808 | return Ok(Outcome::fail(FailureCode::Conflict, why)); | |
| 809 | } | |
| 810 | find(email).filter(|row| Some(row.id.as_str()) != account.primary_email_id.as_deref()) | |
| 811 | } | |
| 812 | }; | |
| 813 | // Some(None): the primary only. | |
| 814 | let backup: Option<Option<EmailRow>> = match a.backup.as_deref().map(str::trim) { | |
| 815 | None => None, | |
| 816 | Some("") => (account.backup_email_id.is_some()).then_some(None), | |
| 817 | Some(email) => { | |
| 818 | let Some(row) = find(email).filter(|row| row.verified_at.is_some()) else { | |
| 819 | return Ok(Outcome::fail(FailureCode::Conflict, "Only a confirmed address on your account can be the backup.")); | |
| 820 | }; | |
| 821 | let will_be_primary = primary.as_ref().map_or(account.primary_email_id.clone(), |row| Some(row.id.clone())); | |
| 822 | if Some(&row.id) == will_be_primary.as_ref() { | |
| 823 | return Ok(Outcome::fail(FailureCode::Conflict, "That is your primary address; choose another for the backup.")); | |
| 824 | } | |
| 825 | (account.backup_email_id.as_deref() != Some(row.id.as_str())).then_some(Some(row)) | |
| 826 | } | |
| 827 | }; | |
| 828 | if (primary.is_some() || backup.is_some()) | |
| 829 | && let Some(refusal) = self.proof(&a.user.id, &a.reauth).await?.refusal() | |
| 830 | { | |
| 831 | return Ok(refusal); | |
| 832 | } | |
| 833 | if let Some(row) = &primary { | |
| 834 | let old = rows.iter().find(|old| Some(old.id.as_str()) == account.primary_email_id.as_deref()); | |
| 835 | self.set_primary(&a.user.id, row).await?; | |
| 836 | self.log_security(&a.user.id, "primary_email_changed", Some(&row.display), None).await; | |
| 837 | // Every confirmed address hears of it, the old primary included. | |
| 838 | self.tell_addresses( | |
| 839 | &a.user.id, | |
| 840 | &a.user.username, | |
| 841 | &format!("{} is now the primary address", row.display), | |
| 842 | old.filter(|old| old.verified_at.is_some()).map(|old| old.display.as_str()), | |
| 843 | ) | |
| 844 | .await; | |
| 845 | self.announce_email("user.primary_email_changed", &a.user.id, false).await; | |
| 846 | } | |
| 847 | if let Some(choice) = &backup { | |
| 848 | self.db | |
| 849 | .prepare("UPDATE users SET backup_email_id = ? WHERE id = ?") | |
| 850 | .bind(&[ | |
| 851 | choice.as_ref().map_or(JsValue::NULL, |row| row.id.as_str().into()), | |
| 852 | a.user.id.as_str().into(), | |
| 853 | ])? | |
| 854 | .run() | |
| 855 | .await?; | |
| 856 | let said = choice.as_ref().map_or("primary only".to_owned(), |row| row.display.clone()); | |
| 857 | self.log_security(&a.user.id, "backup_email_changed", Some(&said), None).await; | |
| 858 | let change = match choice { | |
| 859 | Some(row) => format!("{} now gets security notices too", row.display), | |
| 860 | None => "Security notices now go to the primary address only".to_owned(), | |
| 861 | }; | |
| 862 | self.tell_addresses(&a.user.id, &a.user.username, &change, None).await; | |
| 863 | } | |
| 864 | let private = a.private_email.filter(|private| *private != (account.private_email != 0)); | |
| 865 | let block = a.block_private_pushes.filter(|block| *block != (account.block_private_pushes != 0)); | |
| 866 | if private.is_some() || block.is_some() { | |
| 867 | self.db | |
| 868 | .prepare( | |
| 869 | "UPDATE users SET private_email = COALESCE(?, private_email), | |
| 870 | block_private_pushes = COALESCE(?, block_private_pushes) WHERE id = ?", | |
| 871 | ) | |
| 872 | .bind(&[ | |
| 873 | private.map_or(JsValue::NULL, |on| (on as u8 as f64).into()), | |
| 874 | block.map_or(JsValue::NULL, |on| (on as u8 as f64).into()), | |
| 875 | a.user.id.as_str().into(), | |
| 876 | ])? | |
| 877 | .run() | |
| 878 | .await?; | |
| 879 | let mut said = Vec::new(); | |
| 880 | if let Some(on) = private { | |
| 881 | said.push(if on { "address kept private" } else { "address used on web commits" }); | |
| 882 | } | |
| 883 | if let Some(on) = block { | |
| 884 | said.push(if on { "pushes that expose it refused" } else { "pushes that expose it allowed" }); | |
| 885 | } | |
| 886 | self.log_security(&a.user.id, "email_privacy_changed", Some(&said.join("; ")), None).await; | |
| 887 | } | |
| 888 | self.emails_view(&a.user.id).await | |
| 889 | } | |
| 890 | ||
| 891 | /// Who gets a security notice: every confirmed address when `all`, | |
| 892 | /// otherwise the primary and the backup. | |
| 893 | pub async fn notice_recipients(&self, user_id: &str, all: bool) -> Result<Vec<String>> { | |
| 894 | let Some(account) = self.account_row(user_id).await? else { | |
| 895 | return Ok(Vec::new()); | |
| 896 | }; | |
| 897 | let rows = sorted(self.email_rows(user_id).await?, account.primary_email_id.as_deref()); | |
| 898 | Ok(rows | |
| 899 | .into_iter() | |
| 900 | .filter(|row| row.verified_at.is_some()) | |
| 901 | .filter(|row| { | |
| 902 | all || Some(row.id.as_str()) == account.primary_email_id.as_deref() | |
| 903 | || Some(row.id.as_str()) == account.backup_email_id.as_deref() | |
| 904 | }) | |
| 905 | .map(|row| row.display) | |
| 906 | .collect()) | |
| 907 | } | |
| 908 | ||
| 909 | /// Tells every confirmed address of an account, and `also` (an address | |
| 910 | /// that has just left it), what changed. Best effort. | |
| 911 | pub async fn tell_addresses(&self, user_id: &str, username: &str, change: &str, also: Option<&str>) { | |
| 912 | let mut to = self.notice_recipients(user_id, true).await.unwrap_or_default(); | |
| 913 | if let Some(also) = also | |
| 914 | && !to.iter().any(|known| known.eq_ignore_ascii_case(also)) | |
| 915 | { | |
| 916 | to.push(also.to_owned()); | |
| 917 | } | |
| 918 | for address in to { | |
| 919 | if let Err(error) = email::send_security_notice(&self.env, &address, username, change).await { | |
| 920 | worker::console_error!("security notice failed: {error}"); | |
| 921 | } | |
| 922 | } | |
| 923 | } | |
| 924 | ||
| 925 | /// Tells the primary and the backup what changed. Best effort. | |
| 926 | pub async fn tell_primary_and_backup(&self, user_id: &str, username: &str, change: &str) { | |
| 927 | for address in self.notice_recipients(user_id, false).await.unwrap_or_default() { | |
| 928 | if let Err(error) = email::send_security_notice(&self.env, &address, username, change).await { | |
| 929 | worker::console_error!("security notice failed: {error}"); | |
| 930 | } | |
| 931 | } | |
| 932 | } | |
| 933 | ||
| 934 | async fn announce_email(&self, kind: &'static str, user_id: &str, by_staff: bool) { | |
| 935 | let actor = (!by_staff).then_some(user_id); | |
| 936 | self.announce( | |
| 937 | kind, | |
| 938 | actor, | |
| 939 | UserEmailChanged { | |
| 940 | user_id: user_id.to_owned(), | |
| 941 | by_staff, | |
| 942 | }, | |
| 943 | ) | |
| 944 | .await; | |
| 945 | } | |
| 946 | ||
| 947 | // --- Signing in and resetting by any confirmed address --- | |
| 948 | ||
| 949 | /// The account a password reset for `email` goes to, the address it is | |
| 950 | /// sent to and that address's id: a confirmed address, or else the | |
| 951 | /// unconfirmed address a new account signed up with (following the link | |
| 952 | /// confirms it). | |
| 953 | pub async fn reset_target(&self, email: &str) -> Result<Option<ResetTarget>> { | |
| 954 | let Some(email) = normalize_email(email) else { | |
| 955 | return Ok(None); | |
| 956 | }; | |
| 957 | let confirmed = self | |
| 958 | .db | |
| 959 | .prepare( | |
| 960 | "SELECT u.id AS user_id, u.username, e.id AS email_id, e.display FROM user_emails e | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 961 | JOIN users u ON u.id = e.user_id WHERE e.email = ? AND e.verified_at IS NOT NULL AND u.deleted_at IS NULL", |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 962 | ) |
| 963 | .bind(&[email.as_str().into()])? | |
| 964 | .first::<ResetTarget>(None) | |
| 965 | .await?; | |
| 966 | if confirmed.is_some() { | |
| 967 | return Ok(confirmed); | |
| 968 | } | |
| 969 | self.db | |
| 970 | .prepare( | |
| 971 | "SELECT u.id AS user_id, u.username, e.id AS email_id, e.display FROM user_emails e | |
| 972 | JOIN users u ON u.primary_email_id = e.id | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 973 | WHERE e.email = ? AND e.verified_at IS NULL AND u.deleted_at IS NULL ORDER BY u.created_at, u.id LIMIT 1", |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 974 | ) |
| 975 | .bind(&[email.as_str().into()])? | |
| 976 | .first::<ResetTarget>(None) | |
| 977 | .await | |
| 978 | } | |
| 979 | ||
| 980 | // --- Commits --- | |
| 981 | ||
| 982 | /// `email_owners`: whose commits these are, by author address. | |
| 983 | pub async fn email_owners(&self, a: EmailOwnersArgs) -> Result<HashMap<String, EmailOwner>> { | |
| 984 | #[derive(Deserialize)] | |
| 985 | struct Row { | |
| 986 | email: String, | |
| 987 | id: String, | |
| 988 | username: String, | |
| 989 | avatar: Option<String>, | |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 990 | /// 1 for an account that is deleted: purged (its username is |
| 991 | /// in `deleted_users`) or in its window to be restored. | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 992 | #[serde(default)] |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 993 | gone: u8, |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 994 | } |
| 995 | let mut owners = HashMap::new(); | |
| 996 | let mut plain: Vec<String> = Vec::new(); | |
| 997 | let mut by_name: Vec<(String, Option<String>, String)> = Vec::new(); | |
| 998 | for email in a.emails.iter().take(200) { | |
| 999 | let Some(email) = normalize_email(email) else { continue }; | |
| 1000 | if let Some((suffix, username)) = parse_noreply(&email) { | |
| 1001 | by_name.push((username, Some(suffix), email)); | |
| 1002 | } else if let Some(username) = email.strip_suffix("@users.g1t.sh") { | |
| 1003 | // What g1t put on the commits it made before noreply | |
| 1004 | // addresses existed. | |
| 1005 | by_name.push((username.to_owned(), None, email.clone())); | |
| 1006 | } else if !plain.contains(&email) { | |
| 1007 | plain.push(email); | |
| 1008 | } | |
| 1009 | } | |
| 1010 | if !plain.is_empty() { | |
| 1011 | let marks = vec!["?"; plain.len()].join(", "); | |
| 1012 | let bind: Vec<JsValue> = plain.iter().map(|email| email.as_str().into()).collect(); | |
| 1013 | let rows = self | |
| 1014 | .db | |
| 1015 | .prepare(format!( | |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 1016 | "SELECT e.email, u.id, u.username, u.avatar, u.deleted_at IS NOT NULL AS gone |
| 1017 | FROM user_emails e JOIN users u ON u.id = e.user_id | |
| 1018 | WHERE e.verified_at IS NOT NULL AND e.email IN ({marks})" | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 1019 | )) |
| 1020 | .bind(&bind)? | |
| 1021 | .all() | |
| 1022 | .await? | |
| 1023 | .results::<Row>()?; | |
| 1024 | for row in rows { | |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 1025 | owners.insert(row.email, shown_owner(row.id, row.username, row.avatar, row.gone != 0)); |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 1026 | } |
| 1027 | } | |
| 1028 | if !by_name.is_empty() { | |
| 1029 | let names: Vec<&str> = by_name.iter().map(|(name, _, _)| name.as_str()).collect(); | |
| 1030 | let marks = vec!["?"; names.len()].join(", "); | |
| 1031 | let bind: Vec<JsValue> = names.iter().map(|name| (*name).into()).collect(); | |
| 1032 | let rows = self | |
| 1033 | .db | |
| 1034 | .prepare(format!( | |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 1035 | "SELECT username AS email, id, username, avatar, deleted_at IS NOT NULL AS gone FROM users |
| 1036 | WHERE username IN ({marks}) | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 1037 | UNION ALL |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 1038 | SELECT username AS email, user_id AS id, username, NULL AS avatar, 1 AS gone FROM deleted_users |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 1039 | WHERE username IN ({marks})" |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 1040 | )) |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 1041 | .bind(&[bind.clone(), bind].concat())? |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 1042 | .all() |
| 1043 | .await? | |
| 1044 | .results::<Row>()?; | |
| 1045 | for (username, suffix, email) in by_name { | |
| 1046 | if let Some(row) = rows.iter().find(|row| row.username == username) | |
| 1047 | && suffix.as_deref().is_none_or(|suffix| id_suffix(&row.id) == suffix) | |
| 1048 | { | |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 1049 | owners.insert(email, shown_owner(row.id.clone(), row.username.clone(), row.avatar.clone(), row.gone != 0)); |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 1050 | } |
| 1051 | } | |
| 1052 | } | |
| 1053 | Ok(owners) | |
| 1054 | } | |
| 1055 | ||
| 1056 | /// `commit_identity`. | |
| 1057 | pub async fn commit_identity(&self, a: CommitIdentityArgs) -> Result<Option<CommitIdentity>> { | |
| 1058 | #[derive(Deserialize)] | |
| 1059 | struct Row { | |
| 1060 | id: String, | |
| 1061 | username: String, | |
| 1062 | display_name: Option<String>, | |
| 1063 | private_email: u8, | |
| 1064 | primary: Option<String>, | |
| 1065 | verified: u8, | |
| 1066 | } | |
| 1067 | let row = self | |
| 1068 | .db | |
| 1069 | .prepare( | |
| 1070 | "SELECT u.id, u.username, u.display_name, u.private_email, e.email AS \"primary\", | |
| 1071 | e.verified_at IS NOT NULL AS verified | |
| 1072 | FROM users u LEFT JOIN user_emails e ON e.id = u.primary_email_id WHERE u.id = ?", | |
| 1073 | ) | |
| 1074 | .bind(&[a.user_id.as_str().into()])? | |
| 1075 | .first::<Row>(None) | |
| 1076 | .await?; | |
| 1077 | Ok(row.map(|row| { | |
| 1078 | let noreply = noreply_address(&row.id, &row.username); | |
| 1079 | let email = match row.primary { | |
| 1080 | Some(primary) if row.private_email == 0 && row.verified != 0 => primary, | |
| 1081 | _ => noreply, | |
| 1082 | }; | |
| 1083 | let name = row.display_name.filter(|name| !name.trim().is_empty()).unwrap_or(row.username); | |
| 1084 | CommitIdentity { name, email } | |
| 1085 | })) | |
| 1086 | } | |
| 1087 | ||
| 1088 | /// `push_email_guard`: the addresses a push by this person must not | |
| 1089 | /// publish, while they keep their address private and block pushes | |
| 1090 | /// that expose it. One read of the account and one of its addresses. | |
| 1091 | pub async fn push_email_guard(&self, a: CommitIdentityArgs) -> Result<Option<PushEmailGuard>> { | |
| 1092 | let Some(account) = self.account_row(&a.user_id).await? else { | |
| 1093 | return Ok(None); | |
| 1094 | }; | |
| 1095 | if !guards_pushes(&account) { | |
| 1096 | return Ok(None); | |
| 1097 | } | |
| 1098 | let rows = self.email_rows(&a.user_id).await?; | |
| 1099 | Ok(Some(PushEmailGuard { | |
| 1100 | emails: rows.into_iter().filter(|row| row.verified_at.is_some()).map(|row| row.email.to_lowercase()).collect(), | |
| 1101 | noreply: noreply_address(&account.id, &account.username), | |
| 1102 | })) | |
| 1103 | } | |
| 1104 | ||
| 1105 | // --- Staff --- | |
| 1106 | ||
| 1107 | /// `admin_user`. | |
| 1108 | pub async fn admin_user(&self, a: UsernameArgs) -> Result<Option<AdminUser>> { | |
| 1109 | #[derive(Deserialize)] | |
| 1110 | struct Id { | |
| 1111 | id: String, | |
| 1112 | } | |
| 1113 | let found = self | |
| 1114 | .db | |
| 1115 | .prepare("SELECT id FROM users WHERE username = ?") | |
| 1116 | .bind(&[a.username.trim().to_lowercase().into()])? | |
| 1117 | .first::<Id>(None) | |
| 1118 | .await?; | |
| 1119 | let Some(found) = found else { | |
| 1120 | return Ok(None); | |
| 1121 | }; | |
| 1122 | self.admin_user_by_id(&found.id).await | |
| 1123 | } | |
| 1124 | ||
| 1125 | async fn admin_user_by_id(&self, user_id: &str) -> Result<Option<AdminUser>> { | |
| 1126 | let Some(account) = self.account_row(user_id).await? else { | |
| 1127 | return Ok(None); | |
| 1128 | }; | |
| 1129 | let rows = self.email_rows(user_id).await?; | |
| 1130 | let log = self.security_events(user_id, true).await?; | |
| 1131 | let emails = view(&account, rows); | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 1132 | // Whether it can be deleted, and its deletion while it waits to be |
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 1133 | // purged (account_deletion.rs). For each workspace it owns alone, |
| 1134 | // whether staff could delete it with the account: protected, or | |
| 1135 | // billing that cannot settle. | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 1136 | let deleted = self.deleted_account(&account.id).await?; |
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 1137 | let deletion = self |
| 1138 | .account_deletion_facts(&account.id, &account.username, crate::account_deletion::AskBilling::Staff) | |
| 1139 | .await?; | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 1140 | Ok(Some(AdminUser { |
| 1141 | id: account.id, | |
| 1142 | username: account.username, | |
| 1143 | created_at: account.created_at, | |
| 1144 | emails: emails.emails, | |
| 1145 | private_email: emails.private_email, | |
| 1146 | log, | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 1147 | deletion, |
| 1148 | deleted, | |
| Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038) | 1149 | joined_through: self.shared_source(user_id).await?, |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 1150 | suspended: self.user_suspension(user_id).await?, |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 1151 | })) |
| 1152 | } | |
| 1153 | ||
| 1154 | /// `admin_remove_email`. | |
| 1155 | pub async fn admin_remove_email(&self, a: AdminRemoveEmailArgs) -> Result<Outcome<AdminUser>> { | |
| 1156 | let reason = a.reason.trim(); | |
| 1157 | if reason.is_empty() { | |
| 1158 | return Ok(Outcome::fail(FailureCode::Invalid, "Say why the address is being removed; the person sees it.")); | |
| 1159 | } | |
| 1160 | if a.staff.trim().is_empty() { | |
| 1161 | return Ok(Outcome::fail(FailureCode::Invalid, "Staff changes name who made them.")); | |
| 1162 | } | |
| 1163 | let Some(user) = self.admin_user(UsernameArgs { username: a.username.clone() }).await? else { | |
| 1164 | return Ok(Outcome::fail(FailureCode::NotFound, "No such account.")); | |
| 1165 | }; | |
| 1166 | let Some(account) = self.account_row(&user.id).await? else { | |
| 1167 | return Ok(Outcome::fail(FailureCode::NotFound, "No such account.")); | |
| 1168 | }; | |
| 1169 | let email = normalize_email(&a.email).unwrap_or_default(); | |
| 1170 | let rows = sorted(self.email_rows(&user.id).await?, account.primary_email_id.as_deref()); | |
| 1171 | let Some(row) = rows.iter().find(|row| row.email == email).cloned() else { | |
| 1172 | return Ok(Outcome::fail(FailureCode::NotFound, "That address is not on this account.")); | |
| 1173 | }; | |
| 1174 | let confirmed: Vec<&EmailRow> = rows.iter().filter(|other| other.verified_at.is_some()).collect(); | |
| 1175 | if row.verified_at.is_some() && confirmed.len() <= 1 { | |
| 1176 | return Ok(Outcome::fail( | |
| 1177 | FailureCode::Conflict, | |
| 1178 | "That is the account's only confirmed address. The person has to add and confirm another first.", | |
| 1179 | )); | |
| 1180 | } | |
| 1181 | let was_primary = account.primary_email_id.as_deref() == Some(row.id.as_str()); | |
| 1182 | if was_primary { | |
| 1183 | match confirmed.iter().find(|other| other.id != row.id) { | |
| 1184 | Some(next) => self.set_primary(&user.id, next).await?, | |
| 1185 | None => { | |
| 1186 | // An unconfirmed primary on an account with no | |
| 1187 | // confirmed address: it is left without one. | |
| 1188 | self.db | |
| 1189 | .prepare("UPDATE users SET primary_email_id = NULL, email = NULL, email_verified_at = NULL WHERE id = ?") | |
| 1190 | .bind(&[user.id.as_str().into()])? | |
| 1191 | .run() | |
| 1192 | .await?; | |
| 1193 | } | |
| 1194 | } | |
| 1195 | } | |
| 1196 | self.delete_address(&user.id, &row).await?; | |
| 1197 | let staff = (a.staff.trim(), reason); | |
| 1198 | self.log_security(&user.id, "email_removed", Some(&row.display), Some(staff)).await; | |
| 1199 | let removed = row.verified_at.is_some().then_some(row.display.as_str()); | |
| 1200 | self.tell_addresses(&user.id, &user.username, &format!("g1t staff removed {} ({reason})", row.display), removed) | |
| 1201 | .await; | |
| 1202 | self.announce_email("user.email_removed", &user.id, true).await; | |
| 1203 | if was_primary { | |
| 1204 | self.announce_email("user.primary_email_changed", &user.id, true).await; | |
| 1205 | } | |
| 1206 | Ok(match self.admin_user_by_id(&user.id).await? { | |
| 1207 | Some(user) => Outcome::Ok(user), | |
| 1208 | None => Outcome::fail(FailureCode::NotFound, "No such account."), | |
| 1209 | }) | |
| 1210 | } | |
| 1211 | } | |
| 1212 | ||
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 1213 | /// Whether a code and link that stop working at `expires_at` still work |
| 1214 | /// at `now` (both RFC 3339, which sort as they read). | |
| 1215 | pub fn still_works(expires_at: &str, now: &str) -> bool { | |
| 1216 | expires_at > now | |
| 1217 | } | |
| 1218 | ||
| 1219 | /// Which of the account's outstanding codes `code` is, by index: each | |
| 1220 | /// `(token id, code hash)` is compared in constant time, and every one is | |
| 1221 | /// compared whatever matched before it. | |
| 1222 | pub fn matching_code<'a>(key: &[u8], code: &str, sent: impl Iterator<Item = (&'a str, &'a str)>) -> Option<usize> { | |
| 1223 | let mut found = None; | |
| 1224 | for (index, (id, hash)) in sent.enumerate() { | |
| 1225 | let expected = crypto::code_hash(key, id, code); | |
| 1226 | if crypto::same(&expected, hash) && found.is_none() { | |
| 1227 | found = Some(index); | |
| 1228 | } | |
| 1229 | } | |
| 1230 | if code.is_empty() { None } else { found } | |
| 1231 | } | |
| 1232 | ||
| Merge branch 'worktree-agent-ac5b181a013e54348' | 1233 | /// Where a password reset goes. |
| 1234 | #[derive(Debug, Deserialize)] | |
| 1235 | pub struct ResetTarget { | |
| 1236 | pub user_id: String, | |
| 1237 | pub username: String, | |
| 1238 | pub email_id: String, | |
| 1239 | pub display: String, | |
| 1240 | } | |
| 1241 | ||
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 1242 | /// Who an address's commits are shown as: the account, or ghost for a |
| 1243 | /// deleted one. An account in its window to be restored is ghost already, | |
| 1244 | /// as it will be once purged (account_deletion.rs), and is itself again if | |
| 1245 | /// staff restore it, since nothing about it is changed here. | |
| 1246 | fn shown_owner(id: String, username: String, avatar: Option<String>, gone: bool) -> EmailOwner { | |
| 1247 | if gone { | |
| 1248 | EmailOwner { id: GHOST_ID.to_owned(), username: GHOST_USERNAME.to_owned(), avatar: None } | |
| 1249 | } else { | |
| 1250 | EmailOwner { id, username, avatar } | |
| 1251 | } | |
| 1252 | } | |
| 1253 | ||
| Merge branch 'worktree-agent-ac5b181a013e54348' | 1254 | #[cfg(test)] |
| 1255 | mod tests { | |
| 1256 | use super::*; | |
| 1257 | ||
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 1258 | #[test] |
| 1259 | fn a_deleted_account_is_ghost_on_its_commits_until_restored() { | |
| 1260 | let live = shown_owner("usr_ana".into(), "ana".into(), Some("abc".into()), false); | |
| 1261 | assert_eq!((live.id.as_str(), live.username.as_str(), live.avatar.as_deref()), ("usr_ana", "ana", Some("abc"))); | |
| 1262 | // Deleted and restorable, or purged: ghost, with nothing of the account. | |
| 1263 | let gone = shown_owner("usr_ana".into(), "ana".into(), Some("abc".into()), true); | |
| 1264 | assert_eq!((gone.id.as_str(), gone.username.as_str(), gone.avatar), (GHOST_ID, GHOST_USERNAME, None)); | |
| 1265 | } | |
| 1266 | ||
| Merge branch 'worktree-agent-ac5b181a013e54348' | 1267 | fn row(id: &str, email: &str, verified: bool, created: &str) -> EmailRow { |
| 1268 | EmailRow { | |
| 1269 | id: id.into(), | |
| 1270 | email: email.into(), | |
| 1271 | display: email.to_uppercase(), | |
| 1272 | verified_at: verified.then(|| "2026-10-01T00:00:00.000Z".to_owned()), | |
| 1273 | sent_at: None, | |
| 1274 | created_at: created.into(), | |
| 1275 | } | |
| 1276 | } | |
| 1277 | ||
| 1278 | fn account(primary: Option<&str>, backup: Option<&str>, private: bool) -> AccountRow { | |
| 1279 | AccountRow { | |
| 1280 | id: "usr_01j9zq4m8x7k2v5n3b6c1d0efg".into(), | |
| 1281 | username: "ada".into(), | |
| 1282 | primary_email_id: primary.map(Into::into), | |
| 1283 | backup_email_id: backup.map(Into::into), | |
| 1284 | private_email: private as u8, | |
| 1285 | block_private_pushes: 0, | |
| 1286 | created_at: String::new(), | |
| 1287 | } | |
| 1288 | } | |
| 1289 | ||
| 1290 | #[test] | |
| 1291 | fn the_primary_comes_first_then_confirmed_then_the_rest() { | |
| 1292 | let rows = vec![ | |
| 1293 | row("e1", "old@x.io", false, "2026-01-01"), | |
| 1294 | row("e2", "work@x.io", true, "2026-02-01"), | |
| 1295 | row("e3", "home@x.io", true, "2026-03-01"), | |
| 1296 | ]; | |
| 1297 | let order: Vec<String> = sorted(rows, Some("e3")).into_iter().map(|row| row.id).collect(); | |
| 1298 | assert_eq!(order, ["e3", "e2", "e1"]); | |
| 1299 | } | |
| 1300 | ||
| 1301 | #[test] | |
| 1302 | fn the_view_marks_primary_and_backup_and_shows_addresses_as_typed() { | |
| 1303 | let rows = vec![row("e1", "a@x.io", true, "1"), row("e2", "b@x.io", true, "2"), row("e3", "c@x.io", false, "3")]; | |
| 1304 | let seen = view(&account(Some("e1"), Some("e2"), true), rows); | |
| 1305 | assert_eq!(seen.emails[0].email, "A@X.IO"); | |
| 1306 | assert!(seen.emails[0].primary && !seen.emails[0].backup); | |
| 1307 | assert!(seen.emails[1].backup && !seen.emails[1].primary); | |
| 1308 | assert!(!seen.emails[2].verified); | |
| 1309 | assert_eq!(seen.noreply, "6c1d0efg+ada@users.noreply.g1t.sh"); | |
| 1310 | assert_eq!(seen.commit_email, seen.noreply); | |
| 1311 | assert_eq!(seen.limit, 10); | |
| 1312 | } | |
| 1313 | ||
| 1314 | #[test] | |
| 1315 | fn commits_use_the_primary_only_when_the_person_allows_it_and_it_is_confirmed() { | |
| 1316 | let confirmed = row("e1", "a@x.io", true, "1"); | |
| 1317 | let unconfirmed = row("e2", "b@x.io", false, "2"); | |
| 1318 | assert_eq!(commit_email(false, Some(&confirmed), "n@noreply"), "a@x.io"); | |
| 1319 | assert_eq!(commit_email(true, Some(&confirmed), "n@noreply"), "n@noreply"); | |
| 1320 | assert_eq!(commit_email(false, Some(&unconfirmed), "n@noreply"), "n@noreply"); | |
| 1321 | assert_eq!(commit_email(false, None, "n@noreply"), "n@noreply"); | |
| 1322 | } | |
| 1323 | ||
| 1324 | #[test] | |
| 1325 | fn pushes_are_guarded_only_while_private_and_blocking() { | |
| 1326 | let mut ada = account(None, None, true); | |
| 1327 | assert!(!guards_pushes(&ada)); | |
| 1328 | ada.block_private_pushes = 1; | |
| 1329 | assert!(guards_pushes(&ada)); | |
| 1330 | ada.private_email = 0; | |
| 1331 | assert!(!guards_pushes(&ada)); | |
| 1332 | } | |
| 1333 | ||
| 1334 | #[test] | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 1335 | fn a_code_works_for_its_own_link_and_address_only_and_not_after_a_new_email() { |
| 1336 | let key = b"identity key".as_slice(); | |
| 1337 | let first = ("link-1", crypto::code_hash(key, "link-1", "482913")); | |
| 1338 | let other_address = ("link-2", crypto::code_hash(key, "link-2", "100200")); | |
| 1339 | fn sent<'a>(rows: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a str)> { | |
| 1340 | rows.iter().map(|(id, hash)| (*id, hash.as_str())).collect() | |
| 1341 | } | |
| 1342 | let rows = vec![first.clone(), other_address.clone()]; | |
| 1343 | assert_eq!(matching_code(key, "482913", sent(&rows).into_iter()), Some(0)); | |
| 1344 | assert_eq!(matching_code(key, "100200", sent(&rows).into_iter()), Some(1)); | |
| 1345 | // A wrong code, an empty one, or the right one under another key. | |
| 1346 | assert_eq!(matching_code(key, "482914", sent(&rows).into_iter()), None); | |
| 1347 | assert_eq!(matching_code(key, "", sent(&rows).into_iter()), None); | |
| 1348 | assert_eq!(matching_code(b"another key", "482913", sent(&rows).into_iter()), None); | |
| 1349 | // Used, or replaced by a new email: the row is gone, and the new | |
| 1350 | // pair's code is bound to its own link, so the old code fails. | |
| 1351 | let resent = vec![("link-3", crypto::code_hash(key, "link-3", "731055")), other_address]; | |
| 1352 | assert_eq!(matching_code(key, "482913", sent(&resent).into_iter()), None); | |
| 1353 | assert_eq!(matching_code(key, "731055", sent(&resent).into_iter()), Some(0)); | |
| 1354 | } | |
| 1355 | ||
| 1356 | #[test] | |
| 1357 | fn a_code_and_link_stop_working_after_an_hour() { | |
| 1358 | let sent = 1_800_000_000_000; | |
| 1359 | let expires = rfc3339(sent + CONFIRM_TTL_SECONDS * 1000); | |
| 1360 | assert!(still_works(&expires, &rfc3339(sent))); | |
| 1361 | assert!(still_works(&expires, &rfc3339(sent + 59 * 60 * 1000))); | |
| 1362 | assert!(!still_works(&expires, &rfc3339(sent + 60 * 60 * 1000))); | |
| 1363 | assert!(!still_works(&expires, &rfc3339(sent + 61 * 60 * 1000))); | |
| 1364 | // Long enough to switch to a mail app, short enough that six | |
| 1365 | // digits are not worth guessing. | |
| 1366 | assert!((30 * 60..=60 * 60).contains(&CONFIRM_TTL_SECONDS)); | |
| 1367 | } | |
| 1368 | ||
| 1369 | #[test] | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 1370 | fn a_link_can_be_sent_again_after_a_minute() { |
| 1371 | let now = 1_800_000_000_000; | |
| 1372 | assert!(may_resend(None, now)); | |
| 1373 | assert!(!may_resend(Some(&rfc3339(now - 30_000)), now)); | |
| 1374 | assert!(may_resend(Some(&rfc3339(now - 61_000)), now)); | |
| 1375 | } | |
| 1376 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.