Skip to content
1,353 linesCodeBlameRaw

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 look1//! 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 look37
38use std::collections::HashMap;
39
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)40use g1t_contracts::account_deletion::{GHOST_ID, GHOST_USERNAME};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look41use g1t_contracts::accounts::*;
42use g1t_contracts::events::UserEmailChanged;
43use g1t_contracts::identity::{UserArgs, UsernameArgs};
44use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
45use g1t_contracts::{FailureCode, Outcome, new_id};
46use g1t_kit::now_ms;
47use serde::Deserialize;
48use worker::Result;
49use worker::wasm_bindgen::JsValue;
50
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)51use crate::email::Confirming;
52use crate::invites::AwaitingJoin;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look53use crate::security::{PEOPLE_ONLY, is_person};
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)54use crate::throttle::{self, CODE_ACCOUNT, CODE_CLIENT, CODE_THROTTLED, CONFIRM_ACCOUNT};
55use 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.
59pub 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.
62pub 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 look63
64/// One row of `user_emails`.
65#[derive(Clone, Debug, Deserialize)]
66pub 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)]
77struct 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
88const 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.
93fn 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
101fn 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.
113fn 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
120fn 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.
148fn 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`.
154pub fn may_resend(sent_at: Option<&str>, now_ms: u64) -> bool {
155 !is_recent(sent_at, now_ms, RESEND_SECONDS)
156}
157
158impl 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 look253 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 look257 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 look263 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 look268 ))
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 look270 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 look276 }
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 look293 /// `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 look316 }
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 look364 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 look387 }
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 look393 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 look413 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 look454 }
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 look482 .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 look492 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 look504 ])
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 look595 .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 look597 }
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 look605 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 look625 }
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 look632 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 look642 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 look682 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 look722 }
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 look957 )
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 look969 )
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 look988 }
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 look1012 ))
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 look1033 ))
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 look1035 .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 look1049 }
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 each1132 // 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 each1136 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 look1139 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 look1149 }))
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).
1213pub 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.
1220pub 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 look1231/// Where a password reset goes.
1232#[derive(Debug, Deserialize)]
1233pub 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)]
1241mod 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 look1347 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.