forked from yvv/decision
- adresse : refusée au-delà de 64 caractères avant tout décodage (le base58
coûte le carré de la longueur : 80 000 caractères prenaient 38 s) ;
- tâche de fond : 50 bulletins par passe, le moins récemment lu d'abord ;
lu une dernière fois à son échéance, une heure de grâce si l'indexeur se tait ;
- plafond de 20 par identité : en attente, plus lâchés depuis une heure ;
purge des lâchés après un jour, des expirés après une semaine ;
- bulletin Ğecko : seul un commentaire payé le tranche, une copie nue s'ignore ;
- finalité : seul un bloc ≤ squidStatus.height compte ; recherche filtrée
(auteur lié, ou virement vers le compte des votes) et paginée par 50 ;
- « ton vote » : un bulletin en attente plus ancien qu'un vote confirmé se tait ;
- reçu (GET /api/ballots/{id}/receipt) et historique (GET /api/ballots),
pour qui l'a demandé seulement ;
- un nom vu seulement par un vote sur la chaîne reste libre pour un lien d'équipe ;
- totaux affichés isolés (shown_tally) pour une publication par paliers.
Les sondes de la relecture deviennent des tests, vus rouges avant le correctif.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
288 lines
10 KiB
Rust
288 lines
10 KiB
Rust
//! 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; it
|
|
//! takes a place only if it adds a group.
|
|
//!
|
|
//! 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, names};
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
|
|
const MAX_DAYS: i64 = 90;
|
|
const MAX_USES: i64 = 500;
|
|
const MAX_LABEL: 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<TeamLink> {
|
|
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<String> {
|
|
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<AppState>,
|
|
Viewer(viewer): Viewer,
|
|
Json(b): Json<PeekBody>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
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<String>,
|
|
}
|
|
|
|
/// Nobody joins under the name of someone already there, however it is dressed
|
|
/// (case, accents, punctuation): compared on `names::key`. The unique index on
|
|
/// `name_key` settles two openings at the same instant.
|
|
async fn free_name(state: &AppState, raw: &str) -> AppResult<names::Name> {
|
|
let name = names::check(raw).ok_or(AppError::Invalid("name"))?;
|
|
// People already there, and those an invitation still waits for. A Ğ1
|
|
// identity known only from a vote on the chain (never signed in here) is not
|
|
// there: as for any member of the web of trust who never came, its name stays free.
|
|
let mut taken: Vec<String> =
|
|
sqlx::query_scalar("SELECT display_name FROM identities WHERE NOT (kind = 'g1' AND last_seen_at IS NULL)")
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
taken.extend(
|
|
sqlx::query_scalar::<_, String>(
|
|
"SELECT label FROM invitations WHERE used_at IS NULL AND identity_id IS NULL AND expires_at > ?",
|
|
)
|
|
.bind(util::now())
|
|
.fetch_all(&state.db)
|
|
.await?,
|
|
);
|
|
if taken.iter().any(|t| names::key(t) == name.key) {
|
|
return Err(AppError::Conflict("name_taken"));
|
|
}
|
|
Ok(name)
|
|
}
|
|
|
|
/// The groups of the link this identity does not have yet.
|
|
async fn missing_groups(state: &AppState, identity_id: &str, groups: &[String]) -> AppResult<usize> {
|
|
let mine: Vec<String> = sqlx::query_scalar("SELECT group_slug FROM memberships WHERE identity_id = ?")
|
|
.bind(identity_id)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
Ok(groups.iter().filter(|g| !mine.contains(g)).count())
|
|
}
|
|
|
|
pub async fn join(
|
|
State(state): State<AppState>,
|
|
Viewer(viewer): Viewer,
|
|
Json(b): Json<JoinBody>,
|
|
) -> AppResult<Response> {
|
|
let l = open_link(&state, &b.token).await?;
|
|
let groups = slugs(&l.groups);
|
|
// Checked before an opening is counted.
|
|
let name = match &viewer {
|
|
Some(me) => {
|
|
// Back with the session already open: nothing to add, no place taken.
|
|
if missing_groups(&state, &me.id, &groups).await? == 0 {
|
|
return Ok(Json(json!({ "me": me })).into_response());
|
|
}
|
|
None
|
|
}
|
|
None => Some(free_name(&state, b.name.as_deref().unwrap_or_default()).await?),
|
|
};
|
|
// 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());
|
|
let inserted = sqlx::query(
|
|
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at, team_link, name_key)
|
|
VALUES (?, 'invite', ?, 0, 0, ?, ?, ?)",
|
|
)
|
|
.bind(&id)
|
|
.bind(&name.display)
|
|
.bind(util::now())
|
|
.bind(&l.token_hash)
|
|
.bind(&name.key)
|
|
.execute(&state.db)
|
|
.await;
|
|
if let Err(sqlx::Error::Database(e)) = &inserted
|
|
&& e.is_unique_violation()
|
|
{
|
|
// Same name at the same instant: give the place back.
|
|
sqlx::query("UPDATE team_links SET uses = uses - 1 WHERE token_hash = ?")
|
|
.bind(&l.token_hash)
|
|
.execute(&state.db)
|
|
.await?;
|
|
return Err(AppError::Conflict("name_taken"));
|
|
}
|
|
inserted?;
|
|
let session = auth::open_session(&state, &id).await?;
|
|
(id, Some(session))
|
|
}
|
|
(None, None) => return Err(AppError::Invalid("name")),
|
|
};
|
|
auth::grant_groups(&state, &identity_id, &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<String>,
|
|
days: i64,
|
|
max_uses: i64,
|
|
}
|
|
|
|
/// The link is returned once and never stored in clear.
|
|
pub async fn create(
|
|
State(state): State<AppState>,
|
|
User(user): User,
|
|
Json(b): Json<CreateBody>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
admin_only(&user)?;
|
|
let label = b.label.trim();
|
|
if label.is_empty()
|
|
|| label.chars().count() > MAX_LABEL
|
|
|| label.chars().any(char::is_control)
|
|
|| 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<String> = 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<AppState>,
|
|
User(user): User,
|
|
Path(hash): Path<String>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
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<Vec<serde_json::Value>> {
|
|
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())
|
|
}
|