| 1 | //! Shared invite links: one link staff hand to a group (a conference's |
| 2 | //! judges, a post, a community), made in sudo. |
| 3 | //! |
| 4 | //! A shared link makes up to `max_uses` new accounts until it expires or |
| 5 | //! staff revoke it, optionally only for addresses at some email domains. |
| 6 | //! Each use makes a new account, which then makes its own workspace: a |
| 7 | //! shared link never joins anyone to an existing workspace, and uses |
| 8 | //! nobody's allowance. Its code is an ordinary invite code (`g1t-` and |
| 9 | //! eight groups), stored the same way: only its SHA-256, and a copy sealed |
| 10 | //! under IDENTITY_KEY so staff can copy the link again while it is live. |
| 11 | //! |
| 12 | //! Using one goes through [`Identity::create_account`] like any invite |
| 13 | //! (invites.rs): the same per-client failure throttle, the same one answer |
| 14 | //! for a code that is unknown, expired, revoked or used up, and a use |
| 15 | //! taken in the same transaction that makes the account. The statement |
| 16 | //! that takes a use counts the uses taken and adds one only while fewer |
| 17 | //! than `max_uses` are, and the account is made only if that row was |
| 18 | //! added, so people racing for the last use cannot both get one. |
| 19 | //! |
| 20 | //! Each use is a row in `shared_invite_uses`, which also says where the |
| 21 | //! account came from: sudo shows "Joined through <label>". |
| 22 | |
| 23 | use g1t_contracts::identity::*; |
| 24 | use g1t_contracts::time::{SQL_NOW, parse_rfc3339, rfc3339}; |
| 25 | use g1t_contracts::{FailureCode, Outcome, new_id}; |
| 26 | use g1t_kit::now_ms; |
| 27 | use serde::Deserialize; |
| 28 | use worker::Result; |
| 29 | use worker::wasm_bindgen::JsValue; |
| 30 | |
| 31 | use crate::Identity; |
| 32 | use crate::invites::{Refusal, code_hash, code_hint, format_code, new_code_body, normalize_code}; |
| 33 | |
| 34 | const DAY_MS: u64 = 24 * 60 * 60 * 1000; |
| 35 | |
| 36 | /// What sudo's audit log files shared links under: `invites` is a reserved |
| 37 | /// name, so no workspace has it. |
| 38 | pub const AUDIT_ACCOUNT: &str = "invites"; |
| 39 | |
| 40 | /// What someone whose address is at another domain is told. The domains |
| 41 | /// are no secret to whoever holds the link: the sign-up page lists them. |
| 42 | pub fn wrong_domain(domains: &[String]) -> String { |
| 43 | let list = match domains { |
| 44 | [] => String::new(), |
| 45 | [one] => one.clone(), |
| 46 | [rest @ .., last] => format!("{} or {last}", rest.join(", ")), |
| 47 | }; |
| 48 | format!("This invite is only for email addresses at {list}. Sign up with your address there.") |
| 49 | } |
| 50 | |
| 51 | // --- Rules -------------------------------------------------------------------- |
| 52 | |
| 53 | /// Where a shared link stands at `now`. Revoked first, then used up, then |
| 54 | /// expired: what staff did, before what time did. |
| 55 | pub fn shared_status( |
| 56 | revoked: bool, |
| 57 | expires_at: &str, |
| 58 | uses: u32, |
| 59 | max_uses: u32, |
| 60 | now: &str, |
| 61 | ) -> SharedInviteStatus { |
| 62 | if revoked { |
| 63 | SharedInviteStatus::Revoked |
| 64 | } else if uses >= max_uses { |
| 65 | SharedInviteStatus::UsedUp |
| 66 | } else if expires_at <= now { |
| 67 | SharedInviteStatus::Expired |
| 68 | } else { |
| 69 | SharedInviteStatus::Live |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /// Whether `email` is at one of `domains`: the part after the last `@`, |
| 74 | /// exactly (a subdomain is another domain). Any address when `domains` is |
| 75 | /// empty. |
| 76 | pub fn domain_allowed(domains: &[String], email: &str) -> bool { |
| 77 | if domains.is_empty() { |
| 78 | return true; |
| 79 | } |
| 80 | let Some((_, domain)) = email.trim().rsplit_once('@') else { |
| 81 | return false; |
| 82 | }; |
| 83 | domains |
| 84 | .iter() |
| 85 | .any(|allowed| allowed.eq_ignore_ascii_case(domain)) |
| 86 | } |
| 87 | |
| 88 | /// The parts of a shared link that decide whether it makes an account. |
| 89 | #[derive(Debug)] |
| 90 | pub struct SharedAdmits<'a> { |
| 91 | pub status: SharedInviteStatus, |
| 92 | pub domains: &'a [String], |
| 93 | } |
| 94 | |
| 95 | /// Whether a shared link makes an account for `email`. Anything but a |
| 96 | /// live link is [`Refusal::Invalid`], the one answer every unusable code |
| 97 | /// gets, so nobody learns whether a link was used up, revoked or expired. |
| 98 | pub fn shared_admits(link: Option<&SharedAdmits>, email: &str) -> std::result::Result<(), Refusal> { |
| 99 | match link { |
| 100 | Some(link) if link.status == SharedInviteStatus::Live => { |
| 101 | if domain_allowed(link.domains, email) { |
| 102 | Ok(()) |
| 103 | } else { |
| 104 | Err(Refusal::WrongDomain) |
| 105 | } |
| 106 | } |
| 107 | _ => Err(Refusal::Invalid), |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /// One email domain as staff typed it (`@Cloudflare.com ` reads as |
| 112 | /// `cloudflare.com`), if it is one. |
| 113 | pub fn normalize_domain(input: &str) -> Option<String> { |
| 114 | let domain = input |
| 115 | .trim() |
| 116 | .trim_start_matches('@') |
| 117 | .trim_end_matches('.') |
| 118 | .to_ascii_lowercase(); |
| 119 | let well_formed = (3..=253).contains(&domain.len()) |
| 120 | && domain.contains('.') |
| 121 | && domain.split('.').all(|label| { |
| 122 | !label.is_empty() |
| 123 | && label.len() <= 63 |
| 124 | && !label.starts_with('-') |
| 125 | && !label.ends_with('-') |
| 126 | && label |
| 127 | .bytes() |
| 128 | .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') |
| 129 | }); |
| 130 | well_formed.then_some(domain) |
| 131 | } |
| 132 | |
| 133 | /// What staff asked for, checked: what a shared link is made from. |
| 134 | #[derive(Debug, PartialEq, Eq)] |
| 135 | pub struct SharedDraft { |
| 136 | pub label: String, |
| 137 | pub max_uses: u32, |
| 138 | /// RFC 3339. |
| 139 | pub expires_at: String, |
| 140 | pub domains: Vec<String>, |
| 141 | } |
| 142 | |
| 143 | /// The end of `YYYY-MM-DD` (UTC), in g1t's format, if it is a real day. |
| 144 | fn end_of_day(date: &str) -> Option<String> { |
| 145 | let date = date.trim(); |
| 146 | if date.len() != 10 { |
| 147 | return None; |
| 148 | } |
| 149 | let end = format!("{date}T23:59:59.999Z"); |
| 150 | // Round-trips only for a day that exists: not 2026-02-30. |
| 151 | let ms = parse_rfc3339(&end)?; |
| 152 | (rfc3339(ms) == end).then_some(end) |
| 153 | } |
| 154 | |
| 155 | /// Checks what staff asked for at `now_ms`: a label, 1 to 1000 uses, an |
| 156 | /// expiry from today to a year ahead (14 days when none is given), and up |
| 157 | /// to 10 domains. Every problem is said the way sudo shows it. |
| 158 | pub fn check_draft( |
| 159 | label: &str, |
| 160 | max_uses: u32, |
| 161 | expires_on: Option<&str>, |
| 162 | domains: &[String], |
| 163 | now_ms: u64, |
| 164 | ) -> std::result::Result<SharedDraft, String> { |
| 165 | let label = label.split_whitespace().collect::<Vec<_>>().join(" "); |
| 166 | if label.is_empty() { |
| 167 | return Err("Give the link a label, such as Cloudflare judges.".to_owned()); |
| 168 | } |
| 169 | if label.chars().count() > MAX_SHARED_INVITE_LABEL { |
| 170 | return Err(format!( |
| 171 | "Keep the label to {MAX_SHARED_INVITE_LABEL} characters." |
| 172 | )); |
| 173 | } |
| 174 | if !(1..=MAX_SHARED_INVITE_USES).contains(&max_uses) { |
| 175 | return Err(format!( |
| 176 | "A shared link makes between 1 and {MAX_SHARED_INVITE_USES} accounts." |
| 177 | )); |
| 178 | } |
| 179 | let now = rfc3339(now_ms); |
| 180 | let expires_at = match expires_on.map(str::trim).filter(|date| !date.is_empty()) { |
| 181 | None => rfc3339(now_ms + SHARED_INVITE_TTL_DAYS * DAY_MS), |
| 182 | Some(date) => { |
| 183 | let Some(end) = end_of_day(date) else { |
| 184 | return Err("Give the expiry as a date, such as 2026-10-28.".to_owned()); |
| 185 | }; |
| 186 | if end <= now { |
| 187 | return Err("The expiry has passed. Choose today or a later day.".to_owned()); |
| 188 | } |
| 189 | // The last day allowed is a year from today. |
| 190 | if end[..10] > rfc3339(now_ms + SHARED_INVITE_MAX_DAYS * DAY_MS)[..10] { |
| 191 | return Err(format!( |
| 192 | "A shared link works for at most {SHARED_INVITE_MAX_DAYS} days." |
| 193 | )); |
| 194 | } |
| 195 | end |
| 196 | } |
| 197 | }; |
| 198 | let mut checked: Vec<String> = Vec::new(); |
| 199 | for domain in domains |
| 200 | .iter() |
| 201 | .flat_map(|entry| entry.split([',', ' ', '\n', '\r', '\t'])) |
| 202 | { |
| 203 | if domain.trim().is_empty() { |
| 204 | continue; |
| 205 | } |
| 206 | let Some(domain) = normalize_domain(domain) else { |
| 207 | return Err(format!( |
| 208 | "{} is not an email domain. Write domains such as cloudflare.com.", |
| 209 | domain.trim() |
| 210 | )); |
| 211 | }; |
| 212 | if !checked.contains(&domain) { |
| 213 | checked.push(domain); |
| 214 | } |
| 215 | } |
| 216 | if checked.len() > MAX_SHARED_INVITE_DOMAINS { |
| 217 | return Err(format!( |
| 218 | "Limit a link to at most {MAX_SHARED_INVITE_DOMAINS} domains." |
| 219 | )); |
| 220 | } |
| 221 | Ok(SharedDraft { |
| 222 | label, |
| 223 | max_uses, |
| 224 | expires_at, |
| 225 | domains: checked, |
| 226 | }) |
| 227 | } |
| 228 | |
| 229 | /// The domains column as a list. |
| 230 | pub fn domains_of(column: Option<&str>) -> Vec<String> { |
| 231 | column |
| 232 | .unwrap_or_default() |
| 233 | .split(',') |
| 234 | .map(str::trim) |
| 235 | .filter(|domain| !domain.is_empty()) |
| 236 | .map(str::to_owned) |
| 237 | .collect() |
| 238 | } |
| 239 | |
| 240 | // --- Taking a use ------------------------------------------------------------- |
| 241 | |
| 242 | /// Takes one use of shared link `?2` for new account `?1`: adds the row |
| 243 | /// only while the link is not revoked, not expired, and has fewer uses |
| 244 | /// than `max_uses`, counted in this same statement. Runs in one batch |
| 245 | /// (a transaction) with [`make_account_sql`], so either both happen or |
| 246 | /// neither does. |
| 247 | pub fn take_use_sql() -> String { |
| 248 | format!( |
| 249 | "INSERT INTO shared_invite_uses (user_id, shared_invite_id, created_at) |
| 250 | SELECT ?1, s.id, {SQL_NOW} FROM shared_invites s |
| 251 | WHERE s.id = ?2 AND s.revoked_at IS NULL AND s.expires_at > {SQL_NOW} |
| 252 | AND (SELECT count(*) FROM shared_invite_uses u WHERE u.shared_invite_id = s.id) < s.max_uses" |
| 253 | ) |
| 254 | } |
| 255 | |
| 256 | /// Makes the account (`?1` to `?4`: id, username, email, password hash) |
| 257 | /// only if [`take_use_sql`] took a use of link `?5` for it. |
| 258 | pub fn make_account_sql(verified_at: &str) -> String { |
| 259 | format!( |
| 260 | "INSERT INTO users (id, username, email, password_hash, email_verified_at) |
| 261 | SELECT ?1, ?2, ?3, ?4, {verified_at} |
| 262 | WHERE EXISTS (SELECT 1 FROM shared_invite_uses WHERE user_id = ?1 AND shared_invite_id = ?5)" |
| 263 | ) |
| 264 | } |
| 265 | |
| 266 | /// Forgets the sealed code of link `?1` once its last use is taken: there |
| 267 | /// is nothing left to copy. |
| 268 | pub fn seal_used_up_sql() -> &'static str { |
| 269 | "UPDATE shared_invites SET sealed_code = NULL |
| 270 | WHERE id = ?1 AND (SELECT count(*) FROM shared_invite_uses u WHERE u.shared_invite_id = ?1) >= max_uses" |
| 271 | } |
| 272 | |
| 273 | /// Revokes link `?2` for staff member `?1`, once: its sealed code goes |
| 274 | /// with it, and the accounts it made stay. |
| 275 | pub fn revoke_sql() -> String { |
| 276 | format!( |
| 277 | "UPDATE shared_invites SET revoked_at = {SQL_NOW}, revoked_by = ?1, sealed_code = NULL |
| 278 | WHERE id = ?2 AND revoked_at IS NULL RETURNING id" |
| 279 | ) |
| 280 | } |
| 281 | |
| 282 | // --- Rows --------------------------------------------------------------------- |
| 283 | |
| 284 | const COLUMNS: &str = "s.id, s.label, s.hint, s.sealed_code, s.max_uses, s.domains, s.staff, s.created_at, s.expires_at, |
| 285 | s.revoked_at, s.revoked_by, |
| 286 | (SELECT count(*) FROM shared_invite_uses u WHERE u.shared_invite_id = s.id) AS uses |
| 287 | FROM shared_invites s"; |
| 288 | |
| 289 | /// The most shared links sudo lists. |
| 290 | const LIST_LIMIT: usize = 200; |
| 291 | |
| 292 | #[derive(Debug, Deserialize)] |
| 293 | pub struct SharedRow { |
| 294 | pub id: String, |
| 295 | pub label: String, |
| 296 | pub hint: String, |
| 297 | pub sealed_code: Option<String>, |
| 298 | pub max_uses: f64, |
| 299 | pub domains: Option<String>, |
| 300 | pub staff: String, |
| 301 | pub created_at: String, |
| 302 | pub expires_at: String, |
| 303 | pub revoked_at: Option<String>, |
| 304 | pub revoked_by: Option<String>, |
| 305 | pub uses: f64, |
| 306 | } |
| 307 | |
| 308 | impl SharedRow { |
| 309 | pub fn status(&self, now: &str) -> SharedInviteStatus { |
| 310 | shared_status( |
| 311 | self.revoked_at.is_some(), |
| 312 | &self.expires_at, |
| 313 | self.uses as u32, |
| 314 | self.max_uses as u32, |
| 315 | now, |
| 316 | ) |
| 317 | } |
| 318 | |
| 319 | pub fn domains(&self) -> Vec<String> { |
| 320 | domains_of(self.domains.as_deref()) |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | impl Identity { |
| 325 | pub(crate) async fn shared_by_code(&self, code: &str) -> Result<Option<SharedRow>> { |
| 326 | let Some(body) = normalize_code(code) else { |
| 327 | return Ok(None); |
| 328 | }; |
| 329 | self.db |
| 330 | .prepare(format!("SELECT {COLUMNS} WHERE s.code_hash = ?")) |
| 331 | .bind(&[code_hash(&body).into()])? |
| 332 | .first::<SharedRow>(None) |
| 333 | .await |
| 334 | } |
| 335 | |
| 336 | async fn shared_by_id(&self, id: &str) -> Result<Option<SharedRow>> { |
| 337 | self.db |
| 338 | .prepare(format!("SELECT {COLUMNS} WHERE s.id = ?")) |
| 339 | .bind(&[id.into()])? |
| 340 | .first::<SharedRow>(None) |
| 341 | .await |
| 342 | } |
| 343 | |
| 344 | /// The statements that take a use of `link` and make the account, for |
| 345 | /// create_account's batch: the account row comes to exist only if the |
| 346 | /// use was taken for it. |
| 347 | pub(crate) fn shared_account_statements( |
| 348 | &self, |
| 349 | link: &SharedRow, |
| 350 | values: &[JsValue; 4], |
| 351 | verified_at: &str, |
| 352 | ) -> Result<Vec<worker::D1PreparedStatement>> { |
| 353 | let user_id = values[0].clone(); |
| 354 | let mut make = values.to_vec(); |
| 355 | make.push(link.id.as_str().into()); |
| 356 | Ok(vec![ |
| 357 | self.db |
| 358 | .prepare(take_use_sql()) |
| 359 | .bind(&[user_id, link.id.as_str().into()])?, |
| 360 | self.db.prepare(make_account_sql(verified_at)).bind(&make)?, |
| 361 | self.db |
| 362 | .prepare(seal_used_up_sql()) |
| 363 | .bind(&[link.id.as_str().into()])?, |
| 364 | ]) |
| 365 | } |
| 366 | |
| 367 | /// What the sign-up page shows for a live shared link's code. |
| 368 | pub(crate) fn shared_preview(&self, link: &SharedRow) -> InvitePreview { |
| 369 | InvitePreview { |
| 370 | kind: InviteKind::Account, |
| 371 | status: InviteStatus::Pending, |
| 372 | invited_by: None, |
| 373 | workspace: None, |
| 374 | repository: None, |
| 375 | email: None, |
| 376 | address: None, |
| 377 | has_account: false, |
| 378 | for_viewer: None, |
| 379 | expires_at: link.expires_at.clone(), |
| 380 | shared_label: Some(link.label.clone()), |
| 381 | shared_domains: link.domains(), |
| 382 | email_proven: false, |
| 383 | } |
| 384 | } |
| 385 | |
| 386 | /// The shared link an account was made with, if it was. |
| 387 | pub(crate) async fn shared_source(&self, user_id: &str) -> Result<Option<SharedInviteSource>> { |
| 388 | #[derive(Deserialize)] |
| 389 | struct Row { |
| 390 | id: String, |
| 391 | label: String, |
| 392 | } |
| 393 | Ok(self |
| 394 | .db |
| 395 | .prepare( |
| 396 | "SELECT s.id, s.label FROM shared_invite_uses u JOIN shared_invites s ON s.id = u.shared_invite_id |
| 397 | WHERE u.user_id = ?", |
| 398 | ) |
| 399 | .bind(&[user_id.into()])? |
| 400 | .first::<Row>(None) |
| 401 | .await? |
| 402 | .map(|row| SharedInviteSource { id: row.id, label: row.label })) |
| 403 | } |
| 404 | |
| 405 | /// A shared link as staff see it, with its code while it is live. |
| 406 | fn shown_shared(&self, row: SharedRow, accounts: Vec<SharedInviteAccount>) -> SharedInvite { |
| 407 | let status = row.status(&rfc3339(now_ms())); |
| 408 | let code = if status == SharedInviteStatus::Live { |
| 409 | row.sealed_code |
| 410 | .as_deref() |
| 411 | .and_then(|sealed| self.invite_sealer()?.open(sealed, &row.id)) |
| 412 | } else { |
| 413 | None |
| 414 | }; |
| 415 | let domains = row.domains(); |
| 416 | SharedInvite { |
| 417 | id: row.id, |
| 418 | label: row.label, |
| 419 | code, |
| 420 | hint: row.hint, |
| 421 | max_uses: row.max_uses as u32, |
| 422 | uses: row.uses as u32, |
| 423 | domains, |
| 424 | status, |
| 425 | staff: row.staff, |
| 426 | created_at: row.created_at, |
| 427 | expires_at: row.expires_at, |
| 428 | revoked_at: row.revoked_at, |
| 429 | revoked_by: row.revoked_by, |
| 430 | accounts, |
| 431 | } |
| 432 | } |
| 433 | |
| 434 | /// The accounts each of `ids` made, oldest first. |
| 435 | async fn shared_accounts(&self, ids: &[String]) -> Result<Vec<(String, SharedInviteAccount)>> { |
| 436 | if ids.is_empty() { |
| 437 | return Ok(Vec::new()); |
| 438 | } |
| 439 | #[derive(Deserialize)] |
| 440 | struct Row { |
| 441 | shared_invite_id: String, |
| 442 | username: Option<String>, |
| 443 | created_at: String, |
| 444 | } |
| 445 | let marks = vec!["?"; ids.len()].join(", "); |
| 446 | let binds: Vec<JsValue> = ids.iter().map(|id| JsValue::from(id.as_str())).collect(); |
| 447 | Ok(self |
| 448 | .db |
| 449 | .prepare(format!( |
| 450 | "SELECT u.shared_invite_id, us.username, u.created_at FROM shared_invite_uses u |
| 451 | LEFT JOIN users us ON us.id = u.user_id |
| 452 | WHERE u.shared_invite_id IN ({marks}) ORDER BY u.created_at, u.user_id" |
| 453 | )) |
| 454 | .bind(&binds)? |
| 455 | .all() |
| 456 | .await? |
| 457 | .results::<Row>()? |
| 458 | .into_iter() |
| 459 | .map(|row| { |
| 460 | ( |
| 461 | row.shared_invite_id, |
| 462 | SharedInviteAccount { |
| 463 | username: row.username, |
| 464 | joined_at: row.created_at, |
| 465 | }, |
| 466 | ) |
| 467 | }) |
| 468 | .collect()) |
| 469 | } |
| 470 | |
| 471 | /// `admin_shared_invites`. |
| 472 | pub async fn admin_shared_invites(&self) -> Result<Vec<SharedInvite>> { |
| 473 | let rows = self |
| 474 | .db |
| 475 | .prepare(format!( |
| 476 | "SELECT {COLUMNS} ORDER BY s.created_at DESC, s.id DESC LIMIT {LIST_LIMIT}" |
| 477 | )) |
| 478 | .all() |
| 479 | .await? |
| 480 | .results::<SharedRow>()?; |
| 481 | let ids: Vec<String> = rows.iter().map(|row| row.id.clone()).collect(); |
| 482 | let mut accounts = self.shared_accounts(&ids).await?; |
| 483 | Ok(rows |
| 484 | .into_iter() |
| 485 | .map(|row| { |
| 486 | let (mine, rest): (Vec<_>, Vec<_>) = |
| 487 | accounts.drain(..).partition(|(id, _)| *id == row.id); |
| 488 | accounts = rest; |
| 489 | let mine = mine.into_iter().map(|(_, account)| account).collect(); |
| 490 | self.shown_shared(row, mine) |
| 491 | }) |
| 492 | .collect()) |
| 493 | } |
| 494 | |
| 495 | /// `admin_create_shared_invite`. |
| 496 | pub async fn admin_create_shared_invite( |
| 497 | &self, |
| 498 | a: AdminCreateSharedInviteArgs, |
| 499 | ) -> Result<Outcome<SharedInvite>> { |
| 500 | let staff = a.staff.trim(); |
| 501 | if staff.is_empty() { |
| 502 | return Ok(Outcome::fail( |
| 503 | FailureCode::Forbidden, |
| 504 | "Say which staff member is making it.", |
| 505 | )); |
| 506 | } |
| 507 | let now = now_ms(); |
| 508 | let draft = match check_draft( |
| 509 | &a.label, |
| 510 | a.max_uses, |
| 511 | a.expires_on.as_deref(), |
| 512 | &a.domains, |
| 513 | now, |
| 514 | ) { |
| 515 | Ok(draft) => draft, |
| 516 | Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)), |
| 517 | }; |
| 518 | let body = new_code_body(); |
| 519 | let code = format_code(&body); |
| 520 | let id = new_id("sinv", now); |
| 521 | let sealed = self.invite_sealer().map(|sealer| sealer.seal(&code, &id)); |
| 522 | let domains = draft.domains.join(","); |
| 523 | self.db |
| 524 | .prepare( |
| 525 | "INSERT INTO shared_invites (id, label, code_hash, hint, sealed_code, max_uses, domains, staff, created_at, expires_at) |
| 526 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", |
| 527 | ) |
| 528 | .bind(&[ |
| 529 | id.as_str().into(), |
| 530 | draft.label.as_str().into(), |
| 531 | code_hash(&body).into(), |
| 532 | code_hint(&body).into(), |
| 533 | sealed.as_deref().map_or(JsValue::NULL, JsValue::from), |
| 534 | f64::from(draft.max_uses).into(), |
| 535 | if domains.is_empty() { JsValue::NULL } else { domains.as_str().into() }, |
| 536 | staff.into(), |
| 537 | rfc3339(now).into(), |
| 538 | draft.expires_at.as_str().into(), |
| 539 | ])? |
| 540 | .run() |
| 541 | .await?; |
| 542 | let only = if draft.domains.is_empty() { |
| 543 | String::new() |
| 544 | } else { |
| 545 | format!(", only {}", draft.domains.join(", ")) |
| 546 | }; |
| 547 | self.record_for_staff( |
| 548 | AUDIT_ACCOUNT, |
| 549 | "shared_invite_created", |
| 550 | &format!( |
| 551 | "Shared invite link {} ({}) for {}: up to {} accounts until {}{only}", |
| 552 | code_hint(&body), |
| 553 | id, |
| 554 | draft.label, |
| 555 | draft.max_uses, |
| 556 | &draft.expires_at[..10] |
| 557 | ), |
| 558 | staff, |
| 559 | ) |
| 560 | .await; |
| 561 | let Some(row) = self.shared_by_id(&id).await? else { |
| 562 | return Ok(Outcome::fail( |
| 563 | FailureCode::Conflict, |
| 564 | "The link could not be made. Try again.", |
| 565 | )); |
| 566 | }; |
| 567 | let mut shown = self.shown_shared(row, Vec::new()); |
| 568 | shown.code = Some(code); |
| 569 | Ok(Outcome::Ok(shown)) |
| 570 | } |
| 571 | |
| 572 | /// `admin_revoke_shared_invite`. |
| 573 | pub async fn admin_revoke_shared_invite( |
| 574 | &self, |
| 575 | a: AdminRevokeSharedInviteArgs, |
| 576 | ) -> Result<Outcome<SharedInvite>> { |
| 577 | let staff = a.staff.trim(); |
| 578 | if staff.is_empty() { |
| 579 | return Ok(Outcome::fail( |
| 580 | FailureCode::Forbidden, |
| 581 | "Say which staff member is revoking it.", |
| 582 | )); |
| 583 | } |
| 584 | let revoked = self |
| 585 | .db |
| 586 | .prepare(revoke_sql()) |
| 587 | .bind(&[staff.into(), a.id.as_str().into()])? |
| 588 | .first::<serde_json::Value>(None) |
| 589 | .await?; |
| 590 | if revoked.is_none() { |
| 591 | return Ok(Outcome::fail( |
| 592 | FailureCode::Conflict, |
| 593 | "That link is revoked already, or there is no such link.", |
| 594 | )); |
| 595 | } |
| 596 | let Some(row) = self.shared_by_id(&a.id).await? else { |
| 597 | return Ok(Outcome::fail( |
| 598 | FailureCode::NotFound, |
| 599 | "Shared invite link not found.", |
| 600 | )); |
| 601 | }; |
| 602 | self.record_for_staff( |
| 603 | AUDIT_ACCOUNT, |
| 604 | "shared_invite_revoked", |
| 605 | &format!( |
| 606 | "Revoked shared invite link {} ({}) for {} after {} of {} uses", |
| 607 | row.hint, row.id, row.label, row.uses as u32, row.max_uses as u32 |
| 608 | ), |
| 609 | staff, |
| 610 | ) |
| 611 | .await; |
| 612 | let accounts = self |
| 613 | .shared_accounts(std::slice::from_ref(&row.id)) |
| 614 | .await? |
| 615 | .into_iter() |
| 616 | .map(|(_, account)| account) |
| 617 | .collect(); |
| 618 | Ok(Outcome::Ok(self.shown_shared(row, accounts))) |
| 619 | } |
| 620 | } |
| 621 | |
| 622 | #[cfg(test)] |
| 623 | mod tests { |
| 624 | use super::*; |
| 625 | |
| 626 | const NOW: &str = "2026-10-08T12:00:00.000Z"; |
| 627 | const LATER: &str = "2026-10-22T12:00:00.000Z"; |
| 628 | const EARLIER: &str = "2026-10-01T12:00:00.000Z"; |
| 629 | /// 2026-10-08T12:00:00.000Z. |
| 630 | const NOW_MS: u64 = 1_791_460_800_000; |
| 631 | |
| 632 | #[test] |
| 633 | fn the_test_clock_is_what_it_says() { |
| 634 | assert_eq!(rfc3339(NOW_MS), NOW); |
| 635 | } |
| 636 | |
| 637 | #[test] |
| 638 | fn a_link_is_live_until_revoked_used_up_or_expired() { |
| 639 | assert_eq!( |
| 640 | shared_status(false, LATER, 0, 10, NOW), |
| 641 | SharedInviteStatus::Live |
| 642 | ); |
| 643 | assert_eq!( |
| 644 | shared_status(false, LATER, 9, 10, NOW), |
| 645 | SharedInviteStatus::Live |
| 646 | ); |
| 647 | assert_eq!( |
| 648 | shared_status(false, LATER, 10, 10, NOW), |
| 649 | SharedInviteStatus::UsedUp |
| 650 | ); |
| 651 | assert_eq!( |
| 652 | shared_status(false, EARLIER, 3, 10, NOW), |
| 653 | SharedInviteStatus::Expired |
| 654 | ); |
| 655 | // It stops at its expiry, not a moment after. |
| 656 | assert_eq!( |
| 657 | shared_status(false, NOW, 3, 10, NOW), |
| 658 | SharedInviteStatus::Expired |
| 659 | ); |
| 660 | assert_eq!( |
| 661 | shared_status(true, LATER, 3, 10, NOW), |
| 662 | SharedInviteStatus::Revoked |
| 663 | ); |
| 664 | // What staff did comes before what time did. |
| 665 | assert_eq!( |
| 666 | shared_status(true, EARLIER, 10, 10, NOW), |
| 667 | SharedInviteStatus::Revoked |
| 668 | ); |
| 669 | assert_eq!( |
| 670 | shared_status(false, EARLIER, 10, 10, NOW), |
| 671 | SharedInviteStatus::UsedUp |
| 672 | ); |
| 673 | } |
| 674 | |
| 675 | #[test] |
| 676 | fn expired_revoked_and_used_up_links_all_get_the_one_answer() { |
| 677 | let none: [String; 0] = []; |
| 678 | for status in [ |
| 679 | SharedInviteStatus::UsedUp, |
| 680 | SharedInviteStatus::Expired, |
| 681 | SharedInviteStatus::Revoked, |
| 682 | ] { |
| 683 | let link = SharedAdmits { |
| 684 | status, |
| 685 | domains: &none, |
| 686 | }; |
| 687 | assert_eq!( |
| 688 | shared_admits(Some(&link), "ada@example.com"), |
| 689 | Err(Refusal::Invalid), |
| 690 | "{status:?}" |
| 691 | ); |
| 692 | // Even at another domain: a dead link says nothing about whom it was for. |
| 693 | let domains = ["cloudflare.com".to_owned()]; |
| 694 | let bound = SharedAdmits { |
| 695 | status, |
| 696 | domains: &domains, |
| 697 | }; |
| 698 | assert_eq!( |
| 699 | shared_admits(Some(&bound), "eve@example.com"), |
| 700 | Err(Refusal::Invalid) |
| 701 | ); |
| 702 | } |
| 703 | assert_eq!( |
| 704 | shared_admits(None, "ada@example.com"), |
| 705 | Err(Refusal::Invalid) |
| 706 | ); |
| 707 | let live = SharedAdmits { |
| 708 | status: SharedInviteStatus::Live, |
| 709 | domains: &none, |
| 710 | }; |
| 711 | assert_eq!(shared_admits(Some(&live), "anyone@anywhere.dev"), Ok(())); |
| 712 | } |
| 713 | |
| 714 | #[test] |
| 715 | fn a_link_limited_to_domains_admits_only_addresses_there() { |
| 716 | let domains = ["cloudflare.com".to_owned(), "flagon.io".to_owned()]; |
| 717 | let live = SharedAdmits { |
| 718 | status: SharedInviteStatus::Live, |
| 719 | domains: &domains, |
| 720 | }; |
| 721 | assert_eq!(shared_admits(Some(&live), "judge@cloudflare.com"), Ok(())); |
| 722 | assert_eq!(shared_admits(Some(&live), " Judge@CloudFlare.COM "), Ok(())); |
| 723 | assert_eq!(shared_admits(Some(&live), "chase@flagon.io"), Ok(())); |
| 724 | assert_eq!( |
| 725 | shared_admits(Some(&live), "eve@example.com"), |
| 726 | Err(Refusal::WrongDomain) |
| 727 | ); |
| 728 | // A subdomain, or a domain that only ends the same, is another domain. |
| 729 | assert_eq!( |
| 730 | shared_admits(Some(&live), "a@eu.cloudflare.com"), |
| 731 | Err(Refusal::WrongDomain) |
| 732 | ); |
| 733 | assert_eq!( |
| 734 | shared_admits(Some(&live), "a@notcloudflare.com"), |
| 735 | Err(Refusal::WrongDomain) |
| 736 | ); |
| 737 | // The last @ decides. |
| 738 | assert_eq!( |
| 739 | shared_admits(Some(&live), "\"a@cloudflare.com\"@evil.com"), |
| 740 | Err(Refusal::WrongDomain) |
| 741 | ); |
| 742 | assert_eq!( |
| 743 | shared_admits(Some(&live), "no-at-sign"), |
| 744 | Err(Refusal::WrongDomain) |
| 745 | ); |
| 746 | assert_eq!( |
| 747 | wrong_domain(&domains), |
| 748 | "This invite is only for email addresses at cloudflare.com or flagon.io. Sign up with your address there." |
| 749 | ); |
| 750 | assert!( |
| 751 | wrong_domain(&["a.com".into(), "b.com".into(), "c.com".into()]) |
| 752 | .contains("a.com, b.com or c.com") |
| 753 | ); |
| 754 | } |
| 755 | |
| 756 | #[test] |
| 757 | fn a_use_is_taken_only_under_max_uses_in_the_statement_that_takes_it() { |
| 758 | let take = take_use_sql(); |
| 759 | // The count, the cap, revocation and expiry are all checked in the |
| 760 | // insert itself: no read-then-write gap for a race to slip into. |
| 761 | assert!(take.starts_with("INSERT INTO shared_invite_uses")); |
| 762 | assert!(take.contains("(SELECT count(*) FROM shared_invite_uses u WHERE u.shared_invite_id = s.id) < s.max_uses")); |
| 763 | assert!(take.contains("s.revoked_at IS NULL")); |
| 764 | assert!(take.contains(&format!("s.expires_at > {SQL_NOW}"))); |
| 765 | assert!(!take.contains("VALUES")); |
| 766 | // The account is made only if this sign-up took the use. |
| 767 | let make = make_account_sql("NULL"); |
| 768 | assert!(make.starts_with("INSERT INTO users")); |
| 769 | assert!(make.contains("WHERE EXISTS (SELECT 1 FROM shared_invite_uses WHERE user_id = ?1 AND shared_invite_id = ?5)")); |
| 770 | assert!(make.contains("SELECT ?1, ?2, ?3, ?4, NULL")); |
| 771 | // The sealed code goes once the last use does. |
| 772 | assert!(seal_used_up_sql().contains(">= max_uses")); |
| 773 | } |
| 774 | |
| 775 | /// The take-a-use statement's rule, applied to sign-ups one after |
| 776 | /// another as D1 runs them (one writer; each batch a transaction). |
| 777 | fn race(max_uses: u32, signups: u32, revoked: bool, expires_at: &str) -> u32 { |
| 778 | let mut uses = 0; |
| 779 | for _ in 0..signups { |
| 780 | if shared_status(revoked, expires_at, uses, max_uses, NOW) == SharedInviteStatus::Live { |
| 781 | uses += 1; |
| 782 | } |
| 783 | } |
| 784 | uses |
| 785 | } |
| 786 | |
| 787 | #[test] |
| 788 | fn however_many_race_for_it_a_link_never_passes_max_uses() { |
| 789 | assert_eq!(race(1, 50, false, LATER), 1); |
| 790 | assert_eq!(race(25, 1000, false, LATER), 25); |
| 791 | assert_eq!(race(1000, 999, false, LATER), 999); |
| 792 | assert_eq!(race(10, 10, true, LATER), 0); |
| 793 | assert_eq!(race(10, 10, false, EARLIER), 0); |
| 794 | } |
| 795 | |
| 796 | fn draft( |
| 797 | label: &str, |
| 798 | max_uses: u32, |
| 799 | expires_on: Option<&str>, |
| 800 | domains: &[&str], |
| 801 | ) -> std::result::Result<SharedDraft, String> { |
| 802 | let domains: Vec<String> = domains.iter().map(|d| (*d).to_owned()).collect(); |
| 803 | check_draft(label, max_uses, expires_on, &domains, NOW_MS) |
| 804 | } |
| 805 | |
| 806 | #[test] |
| 807 | fn a_link_needs_a_label_and_one_to_a_thousand_uses() { |
| 808 | let made = draft(" Cloudflare judges ", 40, None, &[]).unwrap(); |
| 809 | assert_eq!(made.label, "Cloudflare judges"); |
| 810 | assert_eq!(made.max_uses, 40); |
| 811 | assert!(made.domains.is_empty()); |
| 812 | assert!(draft("", 10, None, &[]).unwrap_err().contains("label")); |
| 813 | assert!(draft(" ", 10, None, &[]).unwrap_err().contains("label")); |
| 814 | assert!(draft(&"x".repeat(MAX_SHARED_INVITE_LABEL + 1), 10, None, &[]).is_err()); |
| 815 | assert!(draft(&"x".repeat(MAX_SHARED_INVITE_LABEL), 10, None, &[]).is_ok()); |
| 816 | assert!( |
| 817 | draft("Judges", 0, None, &[]) |
| 818 | .unwrap_err() |
| 819 | .contains("between 1 and 1000") |
| 820 | ); |
| 821 | assert!(draft("Judges", 1001, None, &[]).is_err()); |
| 822 | assert!(draft("Judges", 1, None, &[]).is_ok()); |
| 823 | assert!(draft("Judges", 1000, None, &[]).is_ok()); |
| 824 | } |
| 825 | |
| 826 | #[test] |
| 827 | fn a_link_expires_in_14_days_unless_given_a_day_within_a_year() { |
| 828 | assert_eq!( |
| 829 | draft("Judges", 10, None, &[]).unwrap().expires_at, |
| 830 | "2026-10-22T12:00:00.000Z" |
| 831 | ); |
| 832 | assert_eq!( |
| 833 | draft("Judges", 10, Some(""), &[]).unwrap().expires_at, |
| 834 | "2026-10-22T12:00:00.000Z" |
| 835 | ); |
| 836 | // A day works until its end, UTC. |
| 837 | assert_eq!( |
| 838 | draft("Judges", 10, Some("2026-10-14"), &[]) |
| 839 | .unwrap() |
| 840 | .expires_at, |
| 841 | "2026-10-14T23:59:59.999Z" |
| 842 | ); |
| 843 | assert_eq!( |
| 844 | draft("Judges", 10, Some("2026-10-08"), &[]) |
| 845 | .unwrap() |
| 846 | .expires_at, |
| 847 | "2026-10-08T23:59:59.999Z" |
| 848 | ); |
| 849 | assert!( |
| 850 | draft("Judges", 10, Some("2026-10-07"), &[]) |
| 851 | .unwrap_err() |
| 852 | .contains("passed") |
| 853 | ); |
| 854 | assert!(draft("Judges", 10, Some("2027-10-08"), &[]).is_ok()); |
| 855 | assert!( |
| 856 | draft("Judges", 10, Some("2027-10-09"), &[]) |
| 857 | .unwrap_err() |
| 858 | .contains("365 days") |
| 859 | ); |
| 860 | for bad in [ |
| 861 | "2026-02-30", |
| 862 | "2026-13-01", |
| 863 | "next week", |
| 864 | "2026-10-8", |
| 865 | "2026/10/14", |
| 866 | ] { |
| 867 | assert!( |
| 868 | draft("Judges", 10, Some(bad), &[]) |
| 869 | .unwrap_err() |
| 870 | .contains("as a date"), |
| 871 | "{bad}" |
| 872 | ); |
| 873 | } |
| 874 | } |
| 875 | |
| 876 | #[test] |
| 877 | fn domains_are_tidied_and_checked() { |
| 878 | let made = draft( |
| 879 | "Judges", |
| 880 | 10, |
| 881 | None, |
| 882 | &["@Cloudflare.com, flagon.io", "cloudflare.com\nexample.dev."], |
| 883 | ) |
| 884 | .unwrap(); |
| 885 | assert_eq!(made.domains, ["cloudflare.com", "flagon.io", "example.dev"]); |
| 886 | assert!(draft("Judges", 10, None, &["not a domain!"]).is_err()); |
| 887 | assert!( |
| 888 | draft("Judges", 10, None, &["localhost"]) |
| 889 | .unwrap_err() |
| 890 | .contains("localhost is not an email domain") |
| 891 | ); |
| 892 | assert!(draft("Judges", 10, None, &["-bad.com"]).is_err()); |
| 893 | let eleven: Vec<String> = (0..11).map(|n| format!("d{n}.com")).collect(); |
| 894 | let eleven: Vec<&str> = eleven.iter().map(String::as_str).collect(); |
| 895 | assert!( |
| 896 | draft("Judges", 10, None, &eleven) |
| 897 | .unwrap_err() |
| 898 | .contains("at most 10") |
| 899 | ); |
| 900 | assert_eq!( |
| 901 | normalize_domain(" @EXAMPLE.com. ").as_deref(), |
| 902 | Some("example.com") |
| 903 | ); |
| 904 | assert_eq!( |
| 905 | domains_of(Some("cloudflare.com,flagon.io")), |
| 906 | ["cloudflare.com", "flagon.io"] |
| 907 | ); |
| 908 | assert!(domains_of(None).is_empty()); |
| 909 | assert!(domains_of(Some("")).is_empty()); |
| 910 | } |
| 911 | |
| 912 | #[test] |
| 913 | fn a_shared_code_is_an_ordinary_invite_code() { |
| 914 | let body = new_code_body(); |
| 915 | let link = format!("https://g1t.sh/register?invite={}", format_code(&body)); |
| 916 | assert_eq!(normalize_code(&link).as_deref(), Some(body.as_str())); |
| 917 | assert_eq!(code_hash(&body).len(), 64); |
| 918 | assert_eq!(AUDIT_ACCOUNT, "invites"); |
| 919 | assert!(g1t_contracts::is_reserved_name(AUDIT_ACCOUNT)); |
| 920 | } |
| 921 | |
| 922 | #[test] |
| 923 | fn revoking_stops_new_accounts_and_forgets_the_code_once() { |
| 924 | let revoke = revoke_sql(); |
| 925 | assert!(revoke.contains("revoked_by = ?1")); |
| 926 | assert!(revoke.contains("sealed_code = NULL")); |
| 927 | // Revoking twice changes nothing the second time. |
| 928 | assert!(revoke.contains("AND revoked_at IS NULL")); |
| 929 | // It deletes nothing: the accounts it made, and their uses, stay. |
| 930 | assert!(!revoke.contains("DELETE")); |
| 931 | let none: [String; 0] = []; |
| 932 | let revoked = SharedAdmits { status: shared_status(true, LATER, 0, 10, NOW), domains: &none }; |
| 933 | assert_eq!(shared_admits(Some(&revoked), "ada@example.com"), Err(Refusal::Invalid)); |
| 934 | } |
| 935 | |
| 936 | #[test] |
| 937 | fn each_use_records_where_the_account_came_from_and_outlives_a_purge() { |
| 938 | // The row that takes a use names the account and the link: sudo's |
| 939 | // "Joined through <label>". |
| 940 | assert!(take_use_sql().contains("INSERT INTO shared_invite_uses (user_id, shared_invite_id, created_at)")); |
| 941 | assert!(take_use_sql().contains("SELECT ?1, s.id,")); |
| 942 | // Purging the account keeps the use, so it is never given back. |
| 943 | let purge = crate::account_deletion::purge_statements(); |
| 944 | assert!(!purge.is_empty()); |
| 945 | assert!(purge.iter().all(|(sql, _)| !sql.contains("shared_invite_uses"))); |
| 946 | } |
| 947 | } |