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