v3 : neuvième relecture — noms uniques, places justes, quantités
- Noms des liens d'équipe : comparés sans casse, accents ni ponctuation (ld_engine::names), lettres latines seulement (pas de sosie cyrillique, grec ou pleine chasse), une ligne ; index unique (migration additive 0003) contre deux ouvertures simultanées sous le même nom. - Une personne déjà connectée ne prend une place que si le lien lui ajoute un groupe ; « Ce n'est pas moi » la déconnecte sur un navigateur partagé ; un lien collé dans le même onglet est relu. - Coller ou couper un mot de quantité n'est pas une coquille (« sur tout » → « surtout », « par tout » → « partout »). - Libellés sans caractère de contrôle ; la soumission revérifie le titre. Boucle : 68 tests Rust, svelte-check, 9 vitest, e2e 13/13. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
-- The key a name collides on (ld_engine::names), set for the names people give
|
||||
-- themselves through a team link: two people never share a name. Additive only.
|
||||
|
||||
ALTER TABLE identities ADD COLUMN name_key TEXT;
|
||||
CREATE UNIQUE INDEX identities_name_key ON identities(name_key) WHERE name_key IS NOT NULL;
|
||||
@@ -132,7 +132,11 @@ pub async fn invite(
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
let label = b.label.trim();
|
||||
if label.is_empty() || label.chars().count() > 60 || ld_engine::edit::has_hidden_controls(label) {
|
||||
if label.is_empty()
|
||||
|| label.chars().count() > 60
|
||||
|| label.chars().any(char::is_control)
|
||||
|| ld_engine::edit::has_hidden_controls(label)
|
||||
{
|
||||
return Err(AppError::Invalid("label"));
|
||||
}
|
||||
// A new link for an existing person is for invitation identities only: a link
|
||||
|
||||
@@ -423,9 +423,8 @@ pub async fn submit(
|
||||
if p.status != "draft" {
|
||||
return Err(AppError::Conflict("not_draft"));
|
||||
}
|
||||
if edit::has_hidden_controls(&p.title) || edit::has_hidden_controls(&p.body) {
|
||||
return Err(AppError::Invalid("hidden_characters"));
|
||||
}
|
||||
// Drafts written under older rules are checked again before the vote opens.
|
||||
check_text(&p.title, &p.body, p.kind == "text")?;
|
||||
let now = util::now();
|
||||
sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(&now)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! 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.
|
||||
//! 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
|
||||
@@ -16,13 +17,13 @@ use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::header::SET_COOKIE;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use ld_engine::edit;
|
||||
use ld_engine::{edit, names};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
const MAX_DAYS: i64 = 90;
|
||||
const MAX_USES: i64 = 500;
|
||||
const MAX_NAME: usize = 60;
|
||||
const MAX_LABEL: usize = 60;
|
||||
|
||||
pub fn link(state: &AppState, token: &str) -> String {
|
||||
format!("{}/equipe#{token}", state.cfg.public_url)
|
||||
@@ -91,37 +92,44 @@ pub struct JoinBody {
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
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"));
|
||||
/// 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"))?;
|
||||
let taken: Vec<String> = sqlx::query_scalar("SELECT display_name FROM identities").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(_) => 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(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());
|
||||
}
|
||||
Some(name.to_owned())
|
||||
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(
|
||||
@@ -139,22 +147,34 @@ pub async fn join(
|
||||
(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, ?, ?)",
|
||||
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)
|
||||
.bind(&name.display)
|
||||
.bind(util::now())
|
||||
.bind(&l.token_hash)
|
||||
.bind(&name.key)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
.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, &slugs(&l.groups), Some(&l.created_by)).await?;
|
||||
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();
|
||||
@@ -185,7 +205,11 @@ pub async fn create(
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
admin_only(&user)?;
|
||||
let label = b.label.trim();
|
||||
if label.is_empty() || label.chars().count() > MAX_NAME || edit::has_hidden_controls(label) {
|
||||
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) {
|
||||
|
||||
@@ -507,8 +507,13 @@ async fn a_team_link_makes_one_identity_per_opening() {
|
||||
|
||||
// Nobody joins under a name already there; the refusal does not count as an opening.
|
||||
let mut second = admin.with(None);
|
||||
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "luna" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")));
|
||||
for taken in ["luna", "LÚNA", "Lu-na", "Yvv"] {
|
||||
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": taken }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")), "{taken}");
|
||||
}
|
||||
// Look-alike letters from other alphabets are not a name.
|
||||
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Lunа" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name")));
|
||||
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Paul" }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_ne!(v["me"]["id"].as_str(), Some(luna.as_str()), "one identity per opening");
|
||||
@@ -522,9 +527,12 @@ async fn a_team_link_makes_one_identity_per_opening() {
|
||||
let token2 = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
|
||||
let (_, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token2 }))).await;
|
||||
assert_eq!(v["signedInAs"], "Luna");
|
||||
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token2 }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["me"]["id"].as_str(), Some(luna.as_str()));
|
||||
for _ in 0..3 {
|
||||
// Back again with the session open: no place taken once the groups are there.
|
||||
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token2 }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["me"]["id"].as_str(), Some(luna.as_str()));
|
||||
}
|
||||
let (s, _) = first.call("GET", "/api/groups/monnaie-libre", None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user