//! Team links — one link an administrator shares with a team, until a date and //! for a number of openings. Each opening asks the person's name and makes //! **their own** invited identity, never an administrator: one person, one //! voice, and access management stays with trustinWallet's administrators. //! Someone already signed in keeps their identity and receives the groups. //! //! Limit, assumed: a person who opens the link twice under two names has two //! identities. Where invited identities vote (the test group), administrators //! see in trustinWallet which link each identity came from. use crate::AppState; use crate::auth::{self, IdentityRow, User, Viewer}; use crate::error::{AppError, AppResult}; use crate::util; use axum::Json; use axum::extract::{Path, State}; use axum::http::header::SET_COOKIE; use axum::response::{IntoResponse, Response}; use ld_engine::edit; use serde::Deserialize; use serde_json::json; const MAX_DAYS: i64 = 90; const MAX_USES: i64 = 500; const MAX_NAME: usize = 60; pub fn link(state: &AppState, token: &str) -> String { format!("{}/equipe#{token}", state.cfg.public_url) } #[derive(sqlx::FromRow)] struct TeamLink { token_hash: String, label: String, groups: String, created_by: String, expires_at: String, } /// A link that still opens: not revoked, not expired, openings left. async fn open_link(state: &AppState, token: &str) -> AppResult { sqlx::query_as::<_, TeamLink>( "SELECT token_hash, label, groups, created_by, expires_at FROM team_links WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? AND uses < max_uses", ) .bind(util::sha256_hex(token.trim())) .bind(util::now()) .fetch_optional(&state.db) .await? .ok_or(AppError::Invalid("invitation_invalid")) } fn slugs(raw: &str) -> Vec { serde_json::from_str(raw).unwrap_or_default() } #[derive(Deserialize)] pub struct PeekBody { token: String, } /// What the link opens, before the person gives a name. pub async fn peek( State(state): State, Viewer(viewer): Viewer, Json(b): Json, ) -> AppResult> { let l = open_link(&state, &b.token).await?; let mut names = Vec::new(); for slug in slugs(&l.groups) { if let Some(name) = sqlx::query_scalar::<_, String>("SELECT name FROM groups WHERE slug = ?") .bind(&slug) .fetch_optional(&state.db) .await? { names.push(name); } } Ok(Json(json!({ "label": l.label, "groups": names, "expiresAt": l.expires_at, "signedInAs": viewer.map(|v| v.display_name), }))) } #[derive(Deserialize)] pub struct JoinBody { token: String, #[serde(default)] name: Option, } fn check_name(raw: &str) -> AppResult<&str> { let name = raw.trim(); let n = name.chars().count(); if !(2..=MAX_NAME).contains(&n) || name.chars().any(char::is_control) || edit::has_hidden_controls(name) { return Err(AppError::Invalid("name")); } Ok(name) } pub async fn join( State(state): State, Viewer(viewer): Viewer, Json(b): Json, ) -> AppResult { let l = open_link(&state, &b.token).await?; // Checked before an opening is counted. let name = match &viewer { Some(_) => None, None => { let name = check_name(b.name.as_deref().unwrap_or_default())?; // Nobody joins under the name of someone already there. let taken: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE display_name = ? COLLATE NOCASE") .bind(name) .fetch_one(&state.db) .await?; if taken > 0 { return Err(AppError::Conflict("name_taken")); } Some(name.to_owned()) } }; // Count the opening first: of two at the last place, one only goes on. let counted = sqlx::query( "UPDATE team_links SET uses = uses + 1 WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? AND uses < max_uses", ) .bind(&l.token_hash) .bind(util::now()) .execute(&state.db) .await?; if counted.rows_affected() != 1 { return Err(AppError::Invalid("invitation_invalid")); } let (identity_id, session) = match (viewer, name) { (Some(me), _) => (me.id, None), (None, Some(name)) => { let id = format!("inv:{}", util::id()); sqlx::query( "INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at, team_link) VALUES (?, 'invite', ?, 0, 0, ?, ?)", ) .bind(&id) .bind(&name) .bind(util::now()) .bind(&l.token_hash) .execute(&state.db) .await?; let session = auth::open_session(&state, &id).await?; (id, Some(session)) } (None, None) => return Err(AppError::Invalid("name")), }; auth::grant_groups(&state, &identity_id, &slugs(&l.groups), Some(&l.created_by)).await?; tracing::info!(link = %&l.token_hash[..8], identity = %identity_id, "team link opened"); let me = auth::load_identity(&state, &identity_id).await?; let mut res = Json(json!({ "me": me })).into_response(); if let Some(cookie) = session { res.headers_mut().append(SET_COOKIE, cookie); } Ok(res) } fn admin_only(user: &IdentityRow) -> AppResult<()> { if trustin_access::manages_access(&user.to_identity()) { Ok(()) } else { Err(AppError::Forbidden) } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateBody { label: String, groups: Vec, days: i64, max_uses: i64, } /// The link is returned once and never stored in clear. pub async fn create( State(state): State, User(user): User, Json(b): Json, ) -> AppResult> { admin_only(&user)?; let label = b.label.trim(); if label.is_empty() || label.chars().count() > MAX_NAME || edit::has_hidden_controls(label) { return Err(AppError::Invalid("label")); } if !(1..=MAX_DAYS).contains(&b.days) || !(1..=MAX_USES).contains(&b.max_uses) { return Err(AppError::Invalid("team_limits")); } let known: Vec = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?; if b.groups.is_empty() || b.groups.iter().any(|g| !known.contains(g)) { return Err(AppError::Invalid("groups")); } let token = util::token(); sqlx::query( "INSERT INTO team_links (token_hash, label, groups, created_by, created_at, expires_at, max_uses) VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(util::sha256_hex(&token)) .bind(label) .bind(serde_json::to_string(&b.groups).unwrap_or_else(|_| "[]".into())) .bind(&user.id) .bind(util::now()) .bind(util::in_days(b.days)) .bind(b.max_uses) .execute(&state.db) .await?; tracing::info!(by = %user.id, days = b.days, max_uses = b.max_uses, "team link created"); Ok(Json(json!({ "link": link(&state, &token) }))) } pub async fn revoke( State(state): State, User(user): User, Path(hash): Path, ) -> AppResult> { admin_only(&user)?; sqlx::query("UPDATE team_links SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL") .bind(util::now()) .bind(&hash) .execute(&state.db) .await?; tracing::info!(by = %user.id, "team link revoked"); Ok(Json(json!({ "ok": true }))) } /// The links still open, for trustinWallet. pub async fn list(state: &AppState) -> AppResult> { let rows: Vec<(String, String, String, String, i64, i64)> = sqlx::query_as( "SELECT token_hash, label, groups, expires_at, max_uses, uses FROM team_links WHERE revoked_at IS NULL AND expires_at > ? ORDER BY created_at DESC", ) .bind(util::now()) .fetch_all(&state.db) .await?; Ok(rows .into_iter() .map(|(id, label, groups, expires, max_uses, uses)| { json!({ "id": id, "label": label, "groups": slugs(&groups), "expiresAt": expires, "maxUses": max_uses, "uses": uses, }) }) .collect()) }