Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
1,372 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.

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

This file's history is long; its oldest lines are credited to the oldest commit read.