judu/ex-egopublic Fork
⑂ a2acfa7
Commits
⬇ Clone ▾
git clone https://git.rickub.com/judu/ex-ego.git
git clone ssh://git@rickub.com/judu/ex-ego.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(inscription): route libre-service derrière une clé, noms d'équipe non uniquesUnverified

Ajoute GET|POST /inscription — identifiant, mot de passe, équipe initiale,
clé — qui n'existe QUE si SIGNUP_KEY est posée (404 en GET comme en POST
sinon). La clé se compare en temps constant et rien ne touche la base tant
qu'elle est fausse. L'inscription connecte directement, sans e-mail ni
confirmation.

Lève l'unicité globale de teams.name (migration 0012) : deux foyers sans lien
peuvent avoir chacun une « Maison ». db::teams::resolve accepte « nom ou id »
et rend One / Ambiguous / None ; exego user add liste les homonymes (id +
membres) et demande laquelle, --team accepte aussi un id. Le champ « équipe
initiale » rejoint un nom unique, crée un nom libre, refuse un nom ambigu.

Deux quotas glissants par IP en mémoire (src/ratelimit.rs, jamais en base) :
20 comptes créés par heure et 10 clés fausses par jour, ce dernier consulté
avant la comparaison de clé pour freiner vraiment la force brute. IP tirée de
X-Forwarded-For puis Forwarded, sinon de la socket.
Julien Durillon committed 2026-08-19T01:17:25+02:00 Browse files
a2acfa7 parent: e824891
modified a11y/audit.mjs +5 -2
@@ -1,6 +1,6 @@
11 // Audit d'accessibilité récurrent — axe-core dans Chrome headless.
22 //
3-// Parcourt les 13 écrans de l'app (viewport mobile 390×844) et échoue
3+// Parcourt les 14 écrans de l'app (viewport mobile 390×844) et échoue
44 // (exit 1) à la moindre violation axe « critical » ou « serious » ;
55 // les violations moindres sont listées en avertissement.
66 //
@@ -71,6 +71,9 @@ const ids = await page.evaluate(async () => {
7171
7272 const pages = [
7373 { name: 'login', url: '/login', anon: true },
74+ // 404 si SIGNUP_KEY n'est pas posée sur le serveur audité — lancer avec
75+ // SIGNUP_KEY=… pour auditer vraiment l'écran d'inscription.
76+ { name: 'signup', url: '/inscription', anon: true },
7477 { name: 'add-choice', url: '/temps/nouveau' },
7578 { name: 'add-step1', url: '/temps/nouveau?mode=manuel' },
7679 ids.categorie && { name: 'add-step2', url: `/temps/nouveau?categorie=${ids.categorie}` },
@@ -84,7 +87,7 @@ const pages = [
8487 { name: 'todo-new', url: '/taches/nouvelle' },
8588 ids.todo && { name: 'todo-done', url: `/taches/${ids.todo}/terminer` },
8689 ].filter(Boolean);
87-if (pages.length < 13) console.warn('⚠ catégorie, saisie ou todo manquante : certains écrans sont sautés.');
90+if (pages.length < 14) console.warn('⚠ catégorie, saisie ou todo manquante : certains écrans sont sautés.');
8891
8992 let blocking = 0;
9093 for (const p of pages) {
@@ -1,6 +1,6 @@
1 // Audit d'accessibilité récurrent — axe-core dans Chrome headless.1 // Audit d'accessibilité récurrent — axe-core dans Chrome headless.
2 //2 //
3-// Parcourt les 13 écrans de l'app (viewport mobile 390×844) et échoue3+// Parcourt les 14 écrans de l'app (viewport mobile 390×844) et échoue
4 // (exit 1) à la moindre violation axe « critical » ou « serious » ;4 // (exit 1) à la moindre violation axe « critical » ou « serious » ;
5 // les violations moindres sont listées en avertissement.5 // les violations moindres sont listées en avertissement.
6 //6 //
@@ -71,6 +71,9 @@ const ids = await page.evaluate(async () => {
71 71
72 const pages = [72 const pages = [
73 { name: 'login', url: '/login', anon: true },73 { name: 'login', url: '/login', anon: true },
74+ // 404 si SIGNUP_KEY n'est pas posée sur le serveur audité — lancer avec
75+ // SIGNUP_KEY=… pour auditer vraiment l'écran d'inscription.
76+ { name: 'signup', url: '/inscription', anon: true },
74 { name: 'add-choice', url: '/temps/nouveau' },77 { name: 'add-choice', url: '/temps/nouveau' },
75 { name: 'add-step1', url: '/temps/nouveau?mode=manuel' },78 { name: 'add-step1', url: '/temps/nouveau?mode=manuel' },
76 ids.categorie && { name: 'add-step2', url: `/temps/nouveau?categorie=${ids.categorie}` },79 ids.categorie && { name: 'add-step2', url: `/temps/nouveau?categorie=${ids.categorie}` },
@@ -84,7 +87,7 @@ const pages = [
84 { name: 'todo-new', url: '/taches/nouvelle' },87 { name: 'todo-new', url: '/taches/nouvelle' },
85 ids.todo && { name: 'todo-done', url: `/taches/${ids.todo}/terminer` },88 ids.todo && { name: 'todo-done', url: `/taches/${ids.todo}/terminer` },
86 ].filter(Boolean);89 ].filter(Boolean);
87-if (pages.length < 13) console.warn('⚠ catégorie, saisie ou todo manquante : certains écrans sont sautés.');90+if (pages.length < 14) console.warn('⚠ catégorie, saisie ou todo manquante : certains écrans sont sautés.');
88 91
89 let blocking = 0;92 let blocking = 0;
90 for (const p of pages) {93 for (const p of pages) {
modified assets/style.css +8 -0
@@ -408,6 +408,14 @@ a.team-pill:hover { filter: brightness(.96); }
408408 .login h1 { font-size: 48px; margin: 0 0 var(--space-2); letter-spacing: -0.02em; text-align: center; }
409409 .login .tagline { font-size: 20px; opacity: .7; margin: 0 auto var(--space-8); max-width: 20em; text-align: center; }
410410 .form-error { font-size: 13px; color: var(--color-accent-700); margin: 0 0 var(--space-3); }
411+/* Aide sous un champ (inscription) — .7 est le plancher AA du projet. */
412+.field-hint { font-size: 13px; opacity: .7; margin: 0 0 var(--space-4); text-wrap: pretty; }
413+/* L'inscription porte trois champs et deux aides là où la connexion en a
414+ deux : logo et titre rétrécissent pour que le formulaire reste visible sans
415+ défiler sur un petit écran. */
416+.signup .login-logo { width: 88px; height: 88px; margin-bottom: var(--space-4); }
417+.signup h1 { font-size: 34px; }
418+.signup .tagline { font-size: 16px; margin-bottom: var(--space-6); }
411419
412420 /* — ajout étape 1 : liste de catégories — */
413421 .step-hint { font-size: 13px; opacity: .7; margin: 0 0 var(--space-4); }
@@ -408,6 +408,14 @@ a.team-pill:hover { filter: brightness(.96); }
408 .login h1 { font-size: 48px; margin: 0 0 var(--space-2); letter-spacing: -0.02em; text-align: center; }408 .login h1 { font-size: 48px; margin: 0 0 var(--space-2); letter-spacing: -0.02em; text-align: center; }
409 .login .tagline { font-size: 20px; opacity: .7; margin: 0 auto var(--space-8); max-width: 20em; text-align: center; }409 .login .tagline { font-size: 20px; opacity: .7; margin: 0 auto var(--space-8); max-width: 20em; text-align: center; }
410 .form-error { font-size: 13px; color: var(--color-accent-700); margin: 0 0 var(--space-3); }410 .form-error { font-size: 13px; color: var(--color-accent-700); margin: 0 0 var(--space-3); }
411+/* Aide sous un champ (inscription) — .7 est le plancher AA du projet. */
412+.field-hint { font-size: 13px; opacity: .7; margin: 0 0 var(--space-4); text-wrap: pretty; }
413+/* L'inscription porte trois champs et deux aides là où la connexion en a
414+ deux : logo et titre rétrécissent pour que le formulaire reste visible sans
415+ défiler sur un petit écran. */
416+.signup .login-logo { width: 88px; height: 88px; margin-bottom: var(--space-4); }
417+.signup h1 { font-size: 34px; }
418+.signup .tagline { font-size: 16px; margin-bottom: var(--space-6); }
411 419
412 /* — ajout étape 1 : liste de catégories — */420 /* — ajout étape 1 : liste de catégories — */
413 .step-hint { font-size: 13px; opacity: .7; margin: 0 0 var(--space-4); }421 .step-hint { font-size: 13px; opacity: .7; margin: 0 0 var(--space-4); }
added migrations/0012_teams_name_not_unique.sql +19 -0
new file mode 100644
@@ -0,0 +1,19 @@
1+-- Les noms d'équipe ne sont plus uniques GLOBALEMENT : deux foyers sans lien
2+-- peuvent chacun avoir une équipe « Maison ». La désambiguïsation se fait
3+-- désormais par identifiant — `exego user add --team` liste les homonymes et
4+-- demande, le champ « équipe initiale » de l'inscription accepte un id.
5+--
6+-- Le nom de contrainte est cherché plutôt qu'écrit en dur : les bases créées
7+-- par 0001 le nomment `teams_name_key`, mais rien ne le garantit.
8+DO $$
9+DECLARE c text;
10+BEGIN
11+ SELECT conname INTO c
12+ FROM pg_constraint
13+ WHERE conrelid = 'teams'::regclass
14+ AND contype = 'u'
15+ AND pg_get_constraintdef(oid) = 'UNIQUE (name)';
16+ IF c IS NOT NULL THEN
17+ EXECUTE format('ALTER TABLE teams DROP CONSTRAINT %I', c);
18+ END IF;
19+END $$;
new file mode 100644
@@ -0,0 +1,19 @@
1+-- Les noms d'équipe ne sont plus uniques GLOBALEMENT : deux foyers sans lien
2+-- peuvent chacun avoir une équipe « Maison ». La désambiguïsation se fait
3+-- désormais par identifiant — `exego user add --team` liste les homonymes et
4+-- demande, le champ « équipe initiale » de l'inscription accepte un id.
5+--
6+-- Le nom de contrainte est cherché plutôt qu'écrit en dur : les bases créées
7+-- par 0001 le nomment `teams_name_key`, mais rien ne le garantit.
8+DO $$
9+DECLARE c text;
10+BEGIN
11+ SELECT conname INTO c
12+ FROM pg_constraint
13+ WHERE conrelid = 'teams'::regclass
14+ AND contype = 'u'
15+ AND pg_get_constraintdef(oid) = 'UNIQUE (name)';
16+ IF c IS NOT NULL THEN
17+ EXECUTE format('ALTER TABLE teams DROP CONSTRAINT %I', c);
18+ END IF;
19+END $$;
modified src/cli.rs +76 -18
@@ -1,3 +1,5 @@
1+use std::io::Write;
2+
13 use anyhow::{Context, Result, bail};
24 use biscuit_auth::KeyPair;
35 use sqlx::PgPool;
@@ -6,6 +8,7 @@ use uuid::Uuid;
68
79 use crate::auth::password;
810 use crate::config;
11+use crate::db::teams::{Resolved, TeamMatch};
912 use crate::db::{teams, users};
1013
1114 /// Génère un keypair racine et l'affiche (à faire une fois, en local).
@@ -32,15 +35,71 @@ pub async fn team_add(name: &str) -> Result<()> {
3235 bail!("le nom d'équipe ne peut pas être vide");
3336 }
3437 let pool = connect().await?;
35- match teams::create(&pool, name).await {
36- Ok(id) => {
37- println!("Équipe « {name} » créée ({id}).");
38- Ok(())
38+ // Les homonymes sont permis depuis la 0012 : on prévient sans refuser,
39+ // et l'identifiant affiché sert à désigner celle-ci sans ambiguïté.
40+ let homonyms = teams::find_all_by_name(&pool, name).await?;
41+ let id = teams::create(&pool, name).await?;
42+ println!("Équipe « {name} » créée ({id}).");
43+ if !homonyms.is_empty() {
44+ println!(
45+ "⚠ {} autre(s) équipe(s) portent déjà ce nom — désigne celle-ci par son identifiant :",
46+ homonyms.len()
47+ );
48+ for t in &homonyms {
49+ println!(" {}", t.describe());
3950 }
40- Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {
41- bail!("l'équipe « {name} » existe déjà")
51+ }
52+ Ok(())
53+}
54+
55+/// Résout un `--team` (nom ou identifiant) en UNE équipe. Sur homonymes,
56+/// liste et demande. Rend le `TeamMatch` pour que l'appelant puisse nommer
57+/// l'équipe retenue plutôt que de répéter la saisie.
58+async fn pick_team(pool: &sqlx::PgPool, input: &str) -> Result<TeamMatch> {
59+ match teams::resolve(pool, input).await? {
60+ Resolved::One(t) => Ok(t),
61+ Resolved::None { was_id: true } => {
62+ bail!("aucune équipe n'a l'identifiant « {input} »")
4263 }
43- Err(e) => Err(e.into()),
64+ Resolved::None { was_id: false } => {
65+ bail!("l'équipe « {input} » n'existe pas (exego team add)")
66+ }
67+ Resolved::Ambiguous(matches) => choose(matches, input),
68+ }
69+}
70+
71+/// Menu numéroté des homonymes. Accepte le rang ou l'identifiant complet.
72+/// Une entrée fermée (`< /dev/null`, cron) rend la main au lieu d'attendre :
73+/// la liste est déjà affichée, il reste à repasser l'identifiant en argument.
74+fn choose(matches: Vec<TeamMatch>, input: &str) -> Result<TeamMatch> {
75+ println!("Plusieurs équipes portent le nom « {input} » :");
76+ for (i, t) in matches.iter().enumerate() {
77+ println!(" {}) {}", i + 1, t.describe());
78+ }
79+ loop {
80+ print!("Laquelle ? [1-{}] ", matches.len());
81+ std::io::stdout().flush()?;
82+ let mut line = String::new();
83+ if std::io::stdin().read_line(&mut line)? == 0 {
84+ bail!("aucune réponse — relance avec --team <identifiant>");
85+ }
86+ let line = line.trim();
87+ let picked = line
88+ .parse::<usize>()
89+ .ok()
90+ .filter(|n| (1..=matches.len()).contains(n))
91+ .map(|n| n - 1)
92+ .or_else(|| {
93+ let id = line.parse::<uuid::Uuid>().ok()?;
94+ matches.iter().position(|t| t.id == id)
95+ });
96+ if let Some(i) = picked {
97+ return Ok(matches.into_iter().nth(i).expect("rang vérifié"));
98+ }
99+ println!(
100+ "Réponse invalide : un rang entre 1 et {}, ou un identifiant de la liste.",
101+ matches.len()
102+ );
44103 }
45104 }
46105
@@ -68,9 +127,7 @@ pub async fn user_add(username: &str, password_arg: Option<String>, team: &str)
68127 }
69128
70129 let pool = connect().await?;
71- let team_id = teams::find_by_name(&pool, team.trim())
72- .await?
73- .with_context(|| format!("l'équipe « {} » n'existe pas (exego team add)", team.trim()))?;
130+ let team = pick_team(&pool, team.trim()).await?;
74131
75132 let hash = password::hash(&password)?;
76133 let id = Uuid::now_v7();
@@ -90,12 +147,15 @@ pub async fn user_add(username: &str, password_arg: Option<String>, team: &str)
90147 Err(e) => return Err(e.into()),
91148 }
92149 sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)")
93- .bind(team_id)
150+ .bind(team.id)
94151 .bind(id)
95152 .execute(&mut *tx)
96153 .await?;
97154 tx.commit().await?;
98- println!("Utilisateur « {username} » créé ({id}), membre de « {} ».", team.trim());
155+ println!(
156+ "Utilisateur « {username} » créé ({id}), membre de « {} » ({}).",
157+ team.name, team.id
158+ );
99159 Ok(())
100160 }
101161
@@ -108,20 +168,18 @@ pub async fn user_join(username: &str, team: &str) -> Result<()> {
108168 let user = users::find_by_username(&pool, username)
109169 .await?
110170 .with_context(|| format!("l'utilisateur « {username} » n'existe pas"))?;
111- let team_id = teams::find_by_name(&pool, team)
112- .await?
113- .with_context(|| format!("l'équipe « {team} » n'existe pas (exego team add)"))?;
171+ let picked = pick_team(&pool, team).await?;
114172 let res = sqlx::query(
115173 "INSERT INTO team_members (team_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
116174 )
117- .bind(team_id)
175+ .bind(picked.id)
118176 .bind(user.id)
119177 .execute(&pool)
120178 .await?;
121179 if res.rows_affected() == 0 {
122- println!("« {username} » est déjà membre de « {team} ».");
180+ println!("« {username} » est déjà membre de « {} » ({}).", picked.name, picked.id);
123181 } else {
124- println!("« {username} » ajouté à « {team} ».");
182+ println!("« {username} » ajouté à « {} » ({}).", picked.name, picked.id);
125183 }
126184 Ok(())
127185 }
@@ -1,3 +1,5 @@
1+use std::io::Write;
2+
1 use anyhow::{Context, Result, bail};3 use anyhow::{Context, Result, bail};
2 use biscuit_auth::KeyPair;4 use biscuit_auth::KeyPair;
3 use sqlx::PgPool;5 use sqlx::PgPool;
@@ -6,6 +8,7 @@ use uuid::Uuid;
6 8
7 use crate::auth::password;9 use crate::auth::password;
8 use crate::config;10 use crate::config;
11+use crate::db::teams::{Resolved, TeamMatch};
9 use crate::db::{teams, users};12 use crate::db::{teams, users};
10 13
11 /// Génère un keypair racine et l'affiche (à faire une fois, en local).14 /// Génère un keypair racine et l'affiche (à faire une fois, en local).
@@ -32,15 +35,71 @@ pub async fn team_add(name: &str) -> Result<()> {
32 bail!("le nom d'équipe ne peut pas être vide");35 bail!("le nom d'équipe ne peut pas être vide");
33 }36 }
34 let pool = connect().await?;37 let pool = connect().await?;
35- match teams::create(&pool, name).await {38+ // Les homonymes sont permis depuis la 0012 : on prévient sans refuser,
36- Ok(id) => {39+ // et l'identifiant affiché sert à désigner celle-ci sans ambiguïté.
37- println!("Équipe « {name} » créée ({id}).");40+ let homonyms = teams::find_all_by_name(&pool, name).await?;
38- Ok(())41+ let id = teams::create(&pool, name).await?;
42+ println!("Équipe « {name} » créée ({id}).");
43+ if !homonyms.is_empty() {
44+ println!(
45+ "⚠ {} autre(s) équipe(s) portent déjà ce nom — désigne celle-ci par son identifiant :",
46+ homonyms.len()
47+ );
48+ for t in &homonyms {
49+ println!(" {}", t.describe());
39 }50 }
40- Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {51+ }
41- bail!("l'équipe « {name} » existe déjà")52+ Ok(())
53+}
54+
55+/// Résout un `--team` (nom ou identifiant) en UNE équipe. Sur homonymes,
56+/// liste et demande. Rend le `TeamMatch` pour que l'appelant puisse nommer
57+/// l'équipe retenue plutôt que de répéter la saisie.
58+async fn pick_team(pool: &sqlx::PgPool, input: &str) -> Result<TeamMatch> {
59+ match teams::resolve(pool, input).await? {
60+ Resolved::One(t) => Ok(t),
61+ Resolved::None { was_id: true } => {
62+ bail!("aucune équipe n'a l'identifiant « {input} »")
42 }63 }
43- Err(e) => Err(e.into()),64+ Resolved::None { was_id: false } => {
65+ bail!("l'équipe « {input} » n'existe pas (exego team add)")
66+ }
67+ Resolved::Ambiguous(matches) => choose(matches, input),
68+ }
69+}
70+
71+/// Menu numéroté des homonymes. Accepte le rang ou l'identifiant complet.
72+/// Une entrée fermée (`< /dev/null`, cron) rend la main au lieu d'attendre :
73+/// la liste est déjà affichée, il reste à repasser l'identifiant en argument.
74+fn choose(matches: Vec<TeamMatch>, input: &str) -> Result<TeamMatch> {
75+ println!("Plusieurs équipes portent le nom « {input} » :");
76+ for (i, t) in matches.iter().enumerate() {
77+ println!(" {}) {}", i + 1, t.describe());
78+ }
79+ loop {
80+ print!("Laquelle ? [1-{}] ", matches.len());
81+ std::io::stdout().flush()?;
82+ let mut line = String::new();
83+ if std::io::stdin().read_line(&mut line)? == 0 {
84+ bail!("aucune réponse — relance avec --team <identifiant>");
85+ }
86+ let line = line.trim();
87+ let picked = line
88+ .parse::<usize>()
89+ .ok()
90+ .filter(|n| (1..=matches.len()).contains(n))
91+ .map(|n| n - 1)
92+ .or_else(|| {
93+ let id = line.parse::<uuid::Uuid>().ok()?;
94+ matches.iter().position(|t| t.id == id)
95+ });
96+ if let Some(i) = picked {
97+ return Ok(matches.into_iter().nth(i).expect("rang vérifié"));
98+ }
99+ println!(
100+ "Réponse invalide : un rang entre 1 et {}, ou un identifiant de la liste.",
101+ matches.len()
102+ );
44 }103 }
45 }104 }
46 105
@@ -68,9 +127,7 @@ pub async fn user_add(username: &str, password_arg: Option<String>, team: &str)
68 }127 }
69 128
70 let pool = connect().await?;129 let pool = connect().await?;
71- let team_id = teams::find_by_name(&pool, team.trim())130+ let team = pick_team(&pool, team.trim()).await?;
72- .await?
73- .with_context(|| format!("l'équipe « {} » n'existe pas (exego team add)", team.trim()))?;
74 131
75 let hash = password::hash(&password)?;132 let hash = password::hash(&password)?;
76 let id = Uuid::now_v7();133 let id = Uuid::now_v7();
@@ -90,12 +147,15 @@ pub async fn user_add(username: &str, password_arg: Option<String>, team: &str)
90 Err(e) => return Err(e.into()),147 Err(e) => return Err(e.into()),
91 }148 }
92 sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)")149 sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)")
93- .bind(team_id)150+ .bind(team.id)
94 .bind(id)151 .bind(id)
95 .execute(&mut *tx)152 .execute(&mut *tx)
96 .await?;153 .await?;
97 tx.commit().await?;154 tx.commit().await?;
98- println!("Utilisateur « {username} » créé ({id}), membre de « {} ».", team.trim());155+ println!(
156+ "Utilisateur « {username} » créé ({id}), membre de « {} » ({}).",
157+ team.name, team.id
158+ );
99 Ok(())159 Ok(())
100 }160 }
101 161
@@ -108,20 +168,18 @@ pub async fn user_join(username: &str, team: &str) -> Result<()> {
108 let user = users::find_by_username(&pool, username)168 let user = users::find_by_username(&pool, username)
109 .await?169 .await?
110 .with_context(|| format!("l'utilisateur « {username} » n'existe pas"))?;170 .with_context(|| format!("l'utilisateur « {username} » n'existe pas"))?;
111- let team_id = teams::find_by_name(&pool, team)171+ let picked = pick_team(&pool, team).await?;
112- .await?
113- .with_context(|| format!("l'équipe « {team} » n'existe pas (exego team add)"))?;
114 let res = sqlx::query(172 let res = sqlx::query(
115 "INSERT INTO team_members (team_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",173 "INSERT INTO team_members (team_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
116 )174 )
117- .bind(team_id)175+ .bind(picked.id)
118 .bind(user.id)176 .bind(user.id)
119 .execute(&pool)177 .execute(&pool)
120 .await?;178 .await?;
121 if res.rows_affected() == 0 {179 if res.rows_affected() == 0 {
122- println!("« {username} » est déjà membre de « {team} ».");180+ println!("« {username} » est déjà membre de « {} » ({}).", picked.name, picked.id);
123 } else {181 } else {
124- println!("« {username} » ajouté à « {team} ».");182+ println!("« {username} » ajouté à « {} » ({}).", picked.name, picked.id);
125 }183 }
126 Ok(())184 Ok(())
127 }185 }
modified src/config.rs +12 -0
@@ -34,6 +34,18 @@ pub fn default_lang() -> crate::i18n::Lang {
3434 .unwrap_or(crate::i18n::Lang::Fr)
3535 }
3636
37+/// Clé d'inscription libre-service (`SIGNUP_KEY`). `None` = pas de variable,
38+/// donc **pas de route d'inscription du tout** : une instance qui n'a rien
39+/// configuré ne s'ouvre pas toute seule. La valeur doit être longue et
40+/// aléatoire — c'est le seul rempart, il n'y a ni e-mail ni confirmation, et
41+/// l'application ne limite pas le débit des tentatives.
42+pub fn signup_key() -> Option<String> {
43+ std::env::var("SIGNUP_KEY")
44+ .ok()
45+ .map(|k| k.trim().to_string())
46+ .filter(|k| !k.is_empty())
47+}
48+
3749 pub fn biscuit_private_key_hex() -> Result<String> {
3850 std::env::var("BISCUIT_PRIVATE_KEY")
3951 .context("BISCUIT_PRIVATE_KEY doit être définie (générer avec `exego keygen`)")
@@ -34,6 +34,18 @@ pub fn default_lang() -> crate::i18n::Lang {
34 .unwrap_or(crate::i18n::Lang::Fr)34 .unwrap_or(crate::i18n::Lang::Fr)
35 }35 }
36 36
37+/// Clé d'inscription libre-service (`SIGNUP_KEY`). `None` = pas de variable,
38+/// donc **pas de route d'inscription du tout** : une instance qui n'a rien
39+/// configuré ne s'ouvre pas toute seule. La valeur doit être longue et
40+/// aléatoire — c'est le seul rempart, il n'y a ni e-mail ni confirmation, et
41+/// l'application ne limite pas le débit des tentatives.
42+pub fn signup_key() -> Option<String> {
43+ std::env::var("SIGNUP_KEY")
44+ .ok()
45+ .map(|k| k.trim().to_string())
46+ .filter(|k| !k.is_empty())
47+}
48+
37 pub fn biscuit_private_key_hex() -> Result<String> {49 pub fn biscuit_private_key_hex() -> Result<String> {
38 std::env::var("BISCUIT_PRIVATE_KEY")50 std::env::var("BISCUIT_PRIVATE_KEY")
39 .context("BISCUIT_PRIVATE_KEY doit être définie (générer avec `exego keygen`)")51 .context("BISCUIT_PRIVATE_KEY doit être définie (générer avec `exego keygen`)")
modified src/db/teams.rs +77 -6
@@ -39,12 +39,83 @@ pub async fn create_with_member(pool: &PgPool, name: &str, user_id: Uuid) -> sql
3939 Ok(id)
4040 }
4141
42-pub async fn find_by_name(pool: &PgPool, name: &str) -> sqlx::Result<Option<Uuid>> {
43- let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM teams WHERE name = $1")
44- .bind(name)
45- .fetch_optional(pool)
46- .await?;
47- Ok(row.map(|(id,)| id))
42+/// Une équipe désignée par nom ou par id, avec de quoi la reconnaître quand
43+/// des homonymes existent (`teams.name` n'est plus unique — migration 0012).
44+#[derive(sqlx::FromRow)]
45+pub struct TeamMatch {
46+ pub id: Uuid,
47+ pub name: String,
48+ /// Membres actuels, par ordre alphabétique — c'est ce qui distingue deux
49+ /// « Maison » à l'œil nu.
50+ pub members: Vec<String>,
51+}
52+
53+impl TeamMatch {
54+ /// « 019f… — Maison (alex, camille) » / « … (aucun membre) »
55+ pub fn describe(&self) -> String {
56+ if self.members.is_empty() {
57+ format!("{} — {} (aucun membre)", self.id, self.name)
58+ } else {
59+ format!("{} — {} ({})", self.id, self.name, self.members.join(", "))
60+ }
61+ }
62+}
63+
64+const TEAM_MATCH_SELECT: &str = "SELECT e.id, e.name,
65+ COALESCE(
66+ ARRAY_AGG(u.username ORDER BY u.username) FILTER (WHERE u.id IS NOT NULL),
67+ ARRAY[]::text[]
68+ ) AS members
69+ FROM teams e
70+ LEFT JOIN team_members m ON m.team_id = e.id
71+ LEFT JOIN users u ON u.id = m.user_id";
72+
73+/// Toutes les équipes portant ce nom — plusieurs depuis la 0012.
74+/// Tri par id = ordre de création (UUIDv7), donc stable d'un appel à l'autre.
75+pub async fn find_all_by_name(pool: &PgPool, name: &str) -> sqlx::Result<Vec<TeamMatch>> {
76+ sqlx::query_as(&format!(
77+ "{TEAM_MATCH_SELECT} WHERE e.name = $1 GROUP BY e.id, e.name ORDER BY e.id"
78+ ))
79+ .bind(name)
80+ .fetch_all(pool)
81+ .await
82+}
83+
84+pub async fn find_by_id(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<TeamMatch>> {
85+ sqlx::query_as(&format!(
86+ "{TEAM_MATCH_SELECT} WHERE e.id = $1 GROUP BY e.id, e.name"
87+ ))
88+ .bind(id)
89+ .fetch_optional(pool)
90+ .await
91+}
92+
93+/// Ce qu'une saisie « nom ou id » a désigné.
94+pub enum Resolved {
95+ /// Une seule équipe : on peut y aller.
96+ One(TeamMatch),
97+ /// Plusieurs homonymes : il faut trancher par id.
98+ Ambiguous(Vec<TeamMatch>),
99+ /// Aucune — nom libre (à créer) ou id inexistant, selon `was_id`.
100+ None { was_id: bool },
101+}
102+
103+/// Résout une saisie utilisateur : un UUID désigne une équipe précise, tout
104+/// le reste est un nom (potentiellement porté par plusieurs équipes).
105+pub async fn resolve(pool: &PgPool, input: &str) -> sqlx::Result<Resolved> {
106+ let input = input.trim();
107+ if let Ok(id) = input.parse::<Uuid>() {
108+ return Ok(match find_by_id(pool, id).await? {
109+ Some(t) => Resolved::One(t),
110+ None => Resolved::None { was_id: true },
111+ });
112+ }
113+ let mut matches = find_all_by_name(pool, input).await?;
114+ Ok(match matches.len() {
115+ 0 => Resolved::None { was_id: false },
116+ 1 => Resolved::One(matches.remove(0)),
117+ _ => Resolved::Ambiguous(matches),
118+ })
48119 }
49120
50121 pub async fn remove_member(pool: &PgPool, team_id: Uuid, user_id: Uuid) -> sqlx::Result<u64> {
@@ -39,12 +39,83 @@ pub async fn create_with_member(pool: &PgPool, name: &str, user_id: Uuid) -> sql
39 Ok(id)39 Ok(id)
40 }40 }
41 41
42-pub async fn find_by_name(pool: &PgPool, name: &str) -> sqlx::Result<Option<Uuid>> {42+/// Une équipe désignée par nom ou par id, avec de quoi la reconnaître quand
43- let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM teams WHERE name = $1")43+/// des homonymes existent (`teams.name` n'est plus unique — migration 0012).
44- .bind(name)44+#[derive(sqlx::FromRow)]
45- .fetch_optional(pool)45+pub struct TeamMatch {
46- .await?;46+ pub id: Uuid,
47- Ok(row.map(|(id,)| id))47+ pub name: String,
48+ /// Membres actuels, par ordre alphabétique — c'est ce qui distingue deux
49+ /// « Maison » à l'œil nu.
50+ pub members: Vec<String>,
51+}
52+
53+impl TeamMatch {
54+ /// « 019f… — Maison (alex, camille) » / « … (aucun membre) »
55+ pub fn describe(&self) -> String {
56+ if self.members.is_empty() {
57+ format!("{} — {} (aucun membre)", self.id, self.name)
58+ } else {
59+ format!("{} — {} ({})", self.id, self.name, self.members.join(", "))
60+ }
61+ }
62+}
63+
64+const TEAM_MATCH_SELECT: &str = "SELECT e.id, e.name,
65+ COALESCE(
66+ ARRAY_AGG(u.username ORDER BY u.username) FILTER (WHERE u.id IS NOT NULL),
67+ ARRAY[]::text[]
68+ ) AS members
69+ FROM teams e
70+ LEFT JOIN team_members m ON m.team_id = e.id
71+ LEFT JOIN users u ON u.id = m.user_id";
72+
73+/// Toutes les équipes portant ce nom — plusieurs depuis la 0012.
74+/// Tri par id = ordre de création (UUIDv7), donc stable d'un appel à l'autre.
75+pub async fn find_all_by_name(pool: &PgPool, name: &str) -> sqlx::Result<Vec<TeamMatch>> {
76+ sqlx::query_as(&format!(
77+ "{TEAM_MATCH_SELECT} WHERE e.name = $1 GROUP BY e.id, e.name ORDER BY e.id"
78+ ))
79+ .bind(name)
80+ .fetch_all(pool)
81+ .await
82+}
83+
84+pub async fn find_by_id(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<TeamMatch>> {
85+ sqlx::query_as(&format!(
86+ "{TEAM_MATCH_SELECT} WHERE e.id = $1 GROUP BY e.id, e.name"
87+ ))
88+ .bind(id)
89+ .fetch_optional(pool)
90+ .await
91+}
92+
93+/// Ce qu'une saisie « nom ou id » a désigné.
94+pub enum Resolved {
95+ /// Une seule équipe : on peut y aller.
96+ One(TeamMatch),
97+ /// Plusieurs homonymes : il faut trancher par id.
98+ Ambiguous(Vec<TeamMatch>),
99+ /// Aucune — nom libre (à créer) ou id inexistant, selon `was_id`.
100+ None { was_id: bool },
101+}
102+
103+/// Résout une saisie utilisateur : un UUID désigne une équipe précise, tout
104+/// le reste est un nom (potentiellement porté par plusieurs équipes).
105+pub async fn resolve(pool: &PgPool, input: &str) -> sqlx::Result<Resolved> {
106+ let input = input.trim();
107+ if let Ok(id) = input.parse::<Uuid>() {
108+ return Ok(match find_by_id(pool, id).await? {
109+ Some(t) => Resolved::One(t),
110+ None => Resolved::None { was_id: true },
111+ });
112+ }
113+ let mut matches = find_all_by_name(pool, input).await?;
114+ Ok(match matches.len() {
115+ 0 => Resolved::None { was_id: false },
116+ 1 => Resolved::One(matches.remove(0)),
117+ _ => Resolved::Ambiguous(matches),
118+ })
48 }119 }
49 120
50 pub async fn remove_member(pool: &PgPool, team_id: Uuid, user_id: Uuid) -> sqlx::Result<u64> {121 pub async fn remove_member(pool: &PgPool, team_id: Uuid, user_id: Uuid) -> sqlx::Result<u64> {
modified src/db/users.rs +71 -1
@@ -79,7 +79,77 @@ pub async fn set_stats_layout(pool: &PgPool, id: Uuid, layout: &str) -> sqlx::Re
7979 Ok(())
8080 }
8181
82-/// Cible d'une invitation.
82+/// Résultat d'une inscription — l'identifiant pris est un cas NORMAL du
83+/// formulaire, pas une erreur technique.
84+pub enum Signup {
85+ Created { user_id: Uuid, team_id: Uuid },
86+ UsernameTaken,
87+}
88+
89+/// Équipe d'accueil du nouveau compte, déjà résolue par l'appelant.
90+pub enum InitialTeam {
91+ /// Une équipe existante, désignée par nom unique ou par id.
92+ Existing(Uuid),
93+ /// Nom encore libre : on la crée au passage (les homonymes sont permis
94+ /// depuis la 0012, donc cet INSERT ne peut plus entrer en conflit).
95+ Create(String),
96+}
97+
98+/// Inscription libre-service : le compte ET son rattachement d'équipe, en une
99+/// transaction (invariant « jamais d'utilisateur sans équipe »).
100+///
101+/// L'INSERT du compte fait `ON CONFLICT DO NOTHING` : un identifiant pris rend
102+/// une ligne vide au lieu d'avorter la transaction.
103+pub async fn signup(
104+ pool: &PgPool,
105+ username: &str,
106+ password_hash: &str,
107+ lang: &str,
108+ team: InitialTeam,
109+) -> sqlx::Result<Signup> {
110+ let user_id = Uuid::now_v7();
111+ let mut tx = pool.begin().await?;
112+
113+ let created: Option<(Uuid,)> = sqlx::query_as(
114+ "INSERT INTO users (id, username, password_hash, lang) VALUES ($1, $2, $3, $4)
115+ ON CONFLICT (username) DO NOTHING
116+ RETURNING id",
117+ )
118+ .bind(user_id)
119+ .bind(username)
120+ .bind(password_hash)
121+ .bind(lang)
122+ .fetch_optional(&mut *tx)
123+ .await?;
124+ if created.is_none() {
125+ return Ok(Signup::UsernameTaken);
126+ }
127+
128+ let team_id = match team {
129+ InitialTeam::Existing(id) => id,
130+ InitialTeam::Create(name) => {
131+ let id = Uuid::now_v7();
132+ sqlx::query("INSERT INTO teams (id, name) VALUES ($1, $2)")
133+ .bind(id)
134+ .bind(&name)
135+ .execute(&mut *tx)
136+ .await?;
137+ id
138+ }
139+ };
140+
141+ // Équipe supprimée entre la résolution et ici : la clé étrangère fait
142+ // retomber toute la transaction, aucun compte orphelin ne survit.
143+ sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)")
144+ .bind(team_id)
145+ .bind(user_id)
146+ .execute(&mut *tx)
147+ .await?;
148+ tx.commit().await?;
149+ Ok(Signup::Created { user_id, team_id })
150+}
151+
152+/// Cible d'une invitation./// Cible d'une invitation.
83153 pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> {
84154 sqlx::query_as("SELECT id, username FROM users WHERE username = $1")
85155 .bind(username)
@@ -79,7 +79,77 @@ pub async fn set_stats_layout(pool: &PgPool, id: Uuid, layout: &str) -> sqlx::Re
79 Ok(())79 Ok(())
80 }80 }
81 81
82-/// Cible d'une invitation.82+/// Résultat d'une inscription — l'identifiant pris est un cas NORMAL du
83+/// formulaire, pas une erreur technique.
84+pub enum Signup {
85+ Created { user_id: Uuid, team_id: Uuid },
86+ UsernameTaken,
87+}
88+
89+/// Équipe d'accueil du nouveau compte, déjà résolue par l'appelant.
90+pub enum InitialTeam {
91+ /// Une équipe existante, désignée par nom unique ou par id.
92+ Existing(Uuid),
93+ /// Nom encore libre : on la crée au passage (les homonymes sont permis
94+ /// depuis la 0012, donc cet INSERT ne peut plus entrer en conflit).
95+ Create(String),
96+}
97+
98+/// Inscription libre-service : le compte ET son rattachement d'équipe, en une
99+/// transaction (invariant « jamais d'utilisateur sans équipe »).
100+///
101+/// L'INSERT du compte fait `ON CONFLICT DO NOTHING` : un identifiant pris rend
102+/// une ligne vide au lieu d'avorter la transaction.
103+pub async fn signup(
104+ pool: &PgPool,
105+ username: &str,
106+ password_hash: &str,
107+ lang: &str,
108+ team: InitialTeam,
109+) -> sqlx::Result<Signup> {
110+ let user_id = Uuid::now_v7();
111+ let mut tx = pool.begin().await?;
112+
113+ let created: Option<(Uuid,)> = sqlx::query_as(
114+ "INSERT INTO users (id, username, password_hash, lang) VALUES ($1, $2, $3, $4)
115+ ON CONFLICT (username) DO NOTHING
116+ RETURNING id",
117+ )
118+ .bind(user_id)
119+ .bind(username)
120+ .bind(password_hash)
121+ .bind(lang)
122+ .fetch_optional(&mut *tx)
123+ .await?;
124+ if created.is_none() {
125+ return Ok(Signup::UsernameTaken);
126+ }
127+
128+ let team_id = match team {
129+ InitialTeam::Existing(id) => id,
130+ InitialTeam::Create(name) => {
131+ let id = Uuid::now_v7();
132+ sqlx::query("INSERT INTO teams (id, name) VALUES ($1, $2)")
133+ .bind(id)
134+ .bind(&name)
135+ .execute(&mut *tx)
136+ .await?;
137+ id
138+ }
139+ };
140+
141+ // Équipe supprimée entre la résolution et ici : la clé étrangère fait
142+ // retomber toute la transaction, aucun compte orphelin ne survit.
143+ sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)")
144+ .bind(team_id)
145+ .bind(user_id)
146+ .execute(&mut *tx)
147+ .await?;
148+ tx.commit().await?;
149+ Ok(Signup::Created { user_id, team_id })
150+}
151+
152+/// Cible d'une invitation./// Cible d'une invitation.
83 pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> {153 pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> {
84 sqlx::query_as("SELECT id, username FROM users WHERE username = $1")154 sqlx::query_as("SELECT id, username FROM users WHERE username = $1")
85 .bind(username)155 .bind(username)
modified src/i18n.rs +86 -5
@@ -261,10 +261,27 @@ pub struct Tr {
261261 pub toast_team_renamed: &'static str,
262262 pub toast_joined_pat: &'static str,
263263 pub toast_team_left: &'static str,
264- pub err_team_name_taken: &'static str,
265264 pub err_team_name_empty: &'static str,
266265 pub err_last_team: &'static str,
267266 pub err_password_short: &'static str,
267+ // ─── inscription libre-service (SIGNUP_KEY) ───
268+ pub signup_title: &'static str,
269+ pub signup_intro: &'static str,
270+ pub signup_key_label: &'static str,
271+ pub signup_key_hint: &'static str,
272+ pub signup_submit: &'static str,
273+ pub signup_link: &'static str,
274+ pub signup_have_account: &'static str,
275+ pub err_signup_key: &'static str,
276+ pub err_signup_username_taken: &'static str,
277+ pub err_signup_username_invalid: &'static str,
278+ pub signup_team_label: &'static str,
279+ pub signup_team_hint: &'static str,
280+ pub err_signup_team_empty: &'static str,
281+ pub err_signup_team_ambiguous: &'static str,
282+ pub err_signup_team_unknown: &'static str,
283+ pub err_signup_rate_limited: &'static str,
284+ pub err_signup_too_many_attempts: &'static str,
268285 pub err_password_mismatch: &'static str,
269286 pub err_password_wrong: &'static str,
270287 pub invite_unknown_pat: &'static str,
@@ -537,10 +554,26 @@ pub static FR: Tr = Tr {
537554 toast_team_renamed: "Équipe renommée",
538555 toast_joined_pat: "Tu as rejoint {}",
539556 toast_team_left: "Équipe quittée",
540- err_team_name_taken: "Ce nom d'équipe est déjà pris",
541557 err_team_name_empty: "Le nom d'équipe ne peut pas être vide",
542558 err_last_team: "Impossible de quitter ta dernière équipe",
543559 err_password_short: "Le nouveau mot de passe doit faire au moins 8 caractères.",
560+ signup_title: "Créer un compte",
561+ signup_intro: "Choisis un identifiant et un mot de passe, indique ton équipe, et saisis la clé d'inscription.",
562+ signup_key_label: "Clé d'inscription",
563+ signup_key_hint: "Demande-la à quelqu'un qui utilise déjà Ex Ego.",
564+ signup_submit: "Créer le compte",
565+ signup_link: "Créer un compte",
566+ signup_have_account: "J'ai déjà un compte",
567+ err_signup_key: "Clé d'inscription invalide.",
568+ err_signup_username_taken: "Cet identifiant est déjà pris.",
569+ err_signup_username_invalid: "Identifiant invalide : 32 caractères au maximum, sans espace.",
570+ signup_team_label: "Équipe initiale",
571+ signup_team_hint: "Le nom d'une équipe existante pour la rejoindre, ou un nouveau nom pour créer la tienne. Si plusieurs équipes portent le même nom, colle plutôt son identifiant.",
572+ err_signup_team_empty: "Indique une équipe initiale.",
573+ err_signup_team_ambiguous: "Plusieurs équipes portent ce nom : utilise l'identifiant de celle que tu veux rejoindre.",
574+ err_signup_team_unknown: "Aucune équipe ne porte cet identifiant.",
575+ err_signup_rate_limited: "Trop de comptes créés depuis cette connexion. Réessaie dans une heure.",
576+ err_signup_too_many_attempts: "Trop de clés incorrectes depuis cette connexion. Réessaie demain.",
544577 err_password_mismatch: "La confirmation ne correspond pas.",
545578 err_password_wrong: "Mot de passe actuel incorrect.",
546579 invite_unknown_pat: "Aucun compte « {} ».",
@@ -783,10 +816,26 @@ pub static EN: Tr = Tr {
783816 toast_team_renamed: "Team renamed",
784817 toast_joined_pat: "You joined {}",
785818 toast_team_left: "Team left",
786- err_team_name_taken: "This team name is already taken",
787819 err_team_name_empty: "The team name cannot be empty",
788820 err_last_team: "You cannot leave your last team",
789821 err_password_short: "The new password must be at least 8 characters.",
822+ signup_title: "Create an account",
823+ signup_intro: "Pick a username and a password, name your team, then enter the signup key.",
824+ signup_key_label: "Signup key",
825+ signup_key_hint: "Ask someone who already uses Ex Ego for it.",
826+ signup_submit: "Create the account",
827+ signup_link: "Create an account",
828+ signup_have_account: "I already have an account",
829+ err_signup_key: "Invalid signup key.",
830+ err_signup_username_taken: "That username is already taken.",
831+ err_signup_username_invalid: "Invalid username: 32 characters at most, no spaces.",
832+ signup_team_label: "Initial team",
833+ signup_team_hint: "The name of an existing team to join it, or a new name to create your own. If several teams share a name, paste its identifier instead.",
834+ err_signup_team_empty: "Enter an initial team.",
835+ err_signup_team_ambiguous: "Several teams share that name: use the identifier of the one you want to join.",
836+ err_signup_team_unknown: "No team has that identifier.",
837+ err_signup_rate_limited: "Too many accounts created from this connection. Try again in an hour.",
838+ err_signup_too_many_attempts: "Too many wrong keys from this connection. Try again tomorrow.",
790839 err_password_mismatch: "The confirmation does not match.",
791840 err_password_wrong: "Current password is incorrect.",
792841 invite_unknown_pat: "No account “{}”.",
@@ -1029,10 +1078,26 @@ pub static DE: Tr = Tr {
10291078 toast_team_renamed: "Team umbenannt",
10301079 toast_joined_pat: "Du bist {} beigetreten",
10311080 toast_team_left: "Team verlassen",
1032- err_team_name_taken: "Dieser Teamname ist bereits vergeben",
10331081 err_team_name_empty: "Der Teamname darf nicht leer sein",
10341082 err_last_team: "Du kannst dein letztes Team nicht verlassen",
10351083 err_password_short: "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
1084+ signup_title: "Konto erstellen",
1085+ signup_intro: "Wähle einen Benutzernamen und ein Passwort, nenne dein Team und gib den Anmeldeschlüssel ein.",
1086+ signup_key_label: "Anmeldeschlüssel",
1087+ signup_key_hint: "Frag jemanden, der Ex Ego schon nutzt.",
1088+ signup_submit: "Konto erstellen",
1089+ signup_link: "Konto erstellen",
1090+ signup_have_account: "Ich habe schon ein Konto",
1091+ err_signup_key: "Ungültiger Anmeldeschlüssel.",
1092+ err_signup_username_taken: "Dieser Benutzername ist schon vergeben.",
1093+ err_signup_username_invalid: "Ungültiger Benutzername: höchstens 32 Zeichen, keine Leerzeichen.",
1094+ signup_team_label: "Erstes Team",
1095+ signup_team_hint: "Der Name eines bestehenden Teams, um beizutreten, oder ein neuer Name für dein eigenes. Tragen mehrere Teams denselben Namen, füge lieber die Kennung ein.",
1096+ err_signup_team_empty: "Gib ein erstes Team an.",
1097+ err_signup_team_ambiguous: "Mehrere Teams tragen diesen Namen: Nimm die Kennung des gewünschten Teams.",
1098+ err_signup_team_unknown: "Kein Team hat diese Kennung.",
1099+ err_signup_rate_limited: "Zu viele Konten von dieser Verbindung erstellt. Versuch es in einer Stunde erneut.",
1100+ err_signup_too_many_attempts: "Zu viele falsche Schlüssel von dieser Verbindung. Versuch es morgen erneut.",
10361101 err_password_mismatch: "Die Bestätigung stimmt nicht überein.",
10371102 err_password_wrong: "Aktuelles Passwort ist falsch.",
10381103 invite_unknown_pat: "Kein Konto „{}“.",
@@ -1275,10 +1340,26 @@ pub static PT_BR: Tr = Tr {
12751340 toast_team_renamed: "Equipe renomeada",
12761341 toast_joined_pat: "Você entrou em {}",
12771342 toast_team_left: "Você saiu da equipe",
1278- err_team_name_taken: "Esse nome de equipe já está em uso",
12791343 err_team_name_empty: "O nome da equipe não pode ficar vazio",
12801344 err_last_team: "Você não pode sair da sua última equipe",
12811345 err_password_short: "A nova senha deve ter pelo menos 8 caracteres.",
1346+ signup_title: "Criar uma conta",
1347+ signup_intro: "Escolha um usuário e uma senha, indique sua equipe e informe a chave de inscrição.",
1348+ signup_key_label: "Chave de inscrição",
1349+ signup_key_hint: "Peça a alguém que já usa o Ex Ego.",
1350+ signup_submit: "Criar a conta",
1351+ signup_link: "Criar uma conta",
1352+ signup_have_account: "Já tenho uma conta",
1353+ err_signup_key: "Chave de inscrição inválida.",
1354+ err_signup_username_taken: "Esse usuário já existe.",
1355+ err_signup_username_invalid: "Usuário inválido: no máximo 32 caracteres, sem espaços.",
1356+ signup_team_label: "Equipe inicial",
1357+ signup_team_hint: "O nome de uma equipe existente para entrar nela, ou um nome novo para criar a sua. Se várias equipes tiverem o mesmo nome, cole o identificador.",
1358+ err_signup_team_empty: "Informe uma equipe inicial.",
1359+ err_signup_team_ambiguous: "Várias equipes têm esse nome: use o identificador da que você quer entrar.",
1360+ err_signup_team_unknown: "Nenhuma equipe tem esse identificador.",
1361+ err_signup_rate_limited: "Contas demais criadas desta conexão. Tente de novo daqui a uma hora.",
1362+ err_signup_too_many_attempts: "Chaves incorretas demais desta conexão. Tente de novo amanhã.",
12821363 err_password_mismatch: "A confirmação não confere.",
12831364 err_password_wrong: "Senha atual incorreta.",
12841365 invite_unknown_pat: "Nenhuma conta “{}”.",
@@ -261,10 +261,27 @@ pub struct Tr {
261 pub toast_team_renamed: &'static str,261 pub toast_team_renamed: &'static str,
262 pub toast_joined_pat: &'static str,262 pub toast_joined_pat: &'static str,
263 pub toast_team_left: &'static str,263 pub toast_team_left: &'static str,
264- pub err_team_name_taken: &'static str,
265 pub err_team_name_empty: &'static str,264 pub err_team_name_empty: &'static str,
266 pub err_last_team: &'static str,265 pub err_last_team: &'static str,
267 pub err_password_short: &'static str,266 pub err_password_short: &'static str,
267+ // ─── inscription libre-service (SIGNUP_KEY) ───
268+ pub signup_title: &'static str,
269+ pub signup_intro: &'static str,
270+ pub signup_key_label: &'static str,
271+ pub signup_key_hint: &'static str,
272+ pub signup_submit: &'static str,
273+ pub signup_link: &'static str,
274+ pub signup_have_account: &'static str,
275+ pub err_signup_key: &'static str,
276+ pub err_signup_username_taken: &'static str,
277+ pub err_signup_username_invalid: &'static str,
278+ pub signup_team_label: &'static str,
279+ pub signup_team_hint: &'static str,
280+ pub err_signup_team_empty: &'static str,
281+ pub err_signup_team_ambiguous: &'static str,
282+ pub err_signup_team_unknown: &'static str,
283+ pub err_signup_rate_limited: &'static str,
284+ pub err_signup_too_many_attempts: &'static str,
268 pub err_password_mismatch: &'static str,285 pub err_password_mismatch: &'static str,
269 pub err_password_wrong: &'static str,286 pub err_password_wrong: &'static str,
270 pub invite_unknown_pat: &'static str,287 pub invite_unknown_pat: &'static str,
@@ -537,10 +554,26 @@ pub static FR: Tr = Tr {
537 toast_team_renamed: "Équipe renommée",554 toast_team_renamed: "Équipe renommée",
538 toast_joined_pat: "Tu as rejoint {}",555 toast_joined_pat: "Tu as rejoint {}",
539 toast_team_left: "Équipe quittée",556 toast_team_left: "Équipe quittée",
540- err_team_name_taken: "Ce nom d'équipe est déjà pris",
541 err_team_name_empty: "Le nom d'équipe ne peut pas être vide",557 err_team_name_empty: "Le nom d'équipe ne peut pas être vide",
542 err_last_team: "Impossible de quitter ta dernière équipe",558 err_last_team: "Impossible de quitter ta dernière équipe",
543 err_password_short: "Le nouveau mot de passe doit faire au moins 8 caractères.",559 err_password_short: "Le nouveau mot de passe doit faire au moins 8 caractères.",
560+ signup_title: "Créer un compte",
561+ signup_intro: "Choisis un identifiant et un mot de passe, indique ton équipe, et saisis la clé d'inscription.",
562+ signup_key_label: "Clé d'inscription",
563+ signup_key_hint: "Demande-la à quelqu'un qui utilise déjà Ex Ego.",
564+ signup_submit: "Créer le compte",
565+ signup_link: "Créer un compte",
566+ signup_have_account: "J'ai déjà un compte",
567+ err_signup_key: "Clé d'inscription invalide.",
568+ err_signup_username_taken: "Cet identifiant est déjà pris.",
569+ err_signup_username_invalid: "Identifiant invalide : 32 caractères au maximum, sans espace.",
570+ signup_team_label: "Équipe initiale",
571+ signup_team_hint: "Le nom d'une équipe existante pour la rejoindre, ou un nouveau nom pour créer la tienne. Si plusieurs équipes portent le même nom, colle plutôt son identifiant.",
572+ err_signup_team_empty: "Indique une équipe initiale.",
573+ err_signup_team_ambiguous: "Plusieurs équipes portent ce nom : utilise l'identifiant de celle que tu veux rejoindre.",
574+ err_signup_team_unknown: "Aucune équipe ne porte cet identifiant.",
575+ err_signup_rate_limited: "Trop de comptes créés depuis cette connexion. Réessaie dans une heure.",
576+ err_signup_too_many_attempts: "Trop de clés incorrectes depuis cette connexion. Réessaie demain.",
544 err_password_mismatch: "La confirmation ne correspond pas.",577 err_password_mismatch: "La confirmation ne correspond pas.",
545 err_password_wrong: "Mot de passe actuel incorrect.",578 err_password_wrong: "Mot de passe actuel incorrect.",
546 invite_unknown_pat: "Aucun compte « {} ».",579 invite_unknown_pat: "Aucun compte « {} ».",
@@ -783,10 +816,26 @@ pub static EN: Tr = Tr {
783 toast_team_renamed: "Team renamed",816 toast_team_renamed: "Team renamed",
784 toast_joined_pat: "You joined {}",817 toast_joined_pat: "You joined {}",
785 toast_team_left: "Team left",818 toast_team_left: "Team left",
786- err_team_name_taken: "This team name is already taken",
787 err_team_name_empty: "The team name cannot be empty",819 err_team_name_empty: "The team name cannot be empty",
788 err_last_team: "You cannot leave your last team",820 err_last_team: "You cannot leave your last team",
789 err_password_short: "The new password must be at least 8 characters.",821 err_password_short: "The new password must be at least 8 characters.",
822+ signup_title: "Create an account",
823+ signup_intro: "Pick a username and a password, name your team, then enter the signup key.",
824+ signup_key_label: "Signup key",
825+ signup_key_hint: "Ask someone who already uses Ex Ego for it.",
826+ signup_submit: "Create the account",
827+ signup_link: "Create an account",
828+ signup_have_account: "I already have an account",
829+ err_signup_key: "Invalid signup key.",
830+ err_signup_username_taken: "That username is already taken.",
831+ err_signup_username_invalid: "Invalid username: 32 characters at most, no spaces.",
832+ signup_team_label: "Initial team",
833+ signup_team_hint: "The name of an existing team to join it, or a new name to create your own. If several teams share a name, paste its identifier instead.",
834+ err_signup_team_empty: "Enter an initial team.",
835+ err_signup_team_ambiguous: "Several teams share that name: use the identifier of the one you want to join.",
836+ err_signup_team_unknown: "No team has that identifier.",
837+ err_signup_rate_limited: "Too many accounts created from this connection. Try again in an hour.",
838+ err_signup_too_many_attempts: "Too many wrong keys from this connection. Try again tomorrow.",
790 err_password_mismatch: "The confirmation does not match.",839 err_password_mismatch: "The confirmation does not match.",
791 err_password_wrong: "Current password is incorrect.",840 err_password_wrong: "Current password is incorrect.",
792 invite_unknown_pat: "No account “{}”.",841 invite_unknown_pat: "No account “{}”.",
@@ -1029,10 +1078,26 @@ pub static DE: Tr = Tr {
1029 toast_team_renamed: "Team umbenannt",1078 toast_team_renamed: "Team umbenannt",
1030 toast_joined_pat: "Du bist {} beigetreten",1079 toast_joined_pat: "Du bist {} beigetreten",
1031 toast_team_left: "Team verlassen",1080 toast_team_left: "Team verlassen",
1032- err_team_name_taken: "Dieser Teamname ist bereits vergeben",
1033 err_team_name_empty: "Der Teamname darf nicht leer sein",1081 err_team_name_empty: "Der Teamname darf nicht leer sein",
1034 err_last_team: "Du kannst dein letztes Team nicht verlassen",1082 err_last_team: "Du kannst dein letztes Team nicht verlassen",
1035 err_password_short: "Das neue Passwort muss mindestens 8 Zeichen lang sein.",1083 err_password_short: "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
1084+ signup_title: "Konto erstellen",
1085+ signup_intro: "Wähle einen Benutzernamen und ein Passwort, nenne dein Team und gib den Anmeldeschlüssel ein.",
1086+ signup_key_label: "Anmeldeschlüssel",
1087+ signup_key_hint: "Frag jemanden, der Ex Ego schon nutzt.",
1088+ signup_submit: "Konto erstellen",
1089+ signup_link: "Konto erstellen",
1090+ signup_have_account: "Ich habe schon ein Konto",
1091+ err_signup_key: "Ungültiger Anmeldeschlüssel.",
1092+ err_signup_username_taken: "Dieser Benutzername ist schon vergeben.",
1093+ err_signup_username_invalid: "Ungültiger Benutzername: höchstens 32 Zeichen, keine Leerzeichen.",
1094+ signup_team_label: "Erstes Team",
1095+ signup_team_hint: "Der Name eines bestehenden Teams, um beizutreten, oder ein neuer Name für dein eigenes. Tragen mehrere Teams denselben Namen, füge lieber die Kennung ein.",
1096+ err_signup_team_empty: "Gib ein erstes Team an.",
1097+ err_signup_team_ambiguous: "Mehrere Teams tragen diesen Namen: Nimm die Kennung des gewünschten Teams.",
1098+ err_signup_team_unknown: "Kein Team hat diese Kennung.",
1099+ err_signup_rate_limited: "Zu viele Konten von dieser Verbindung erstellt. Versuch es in einer Stunde erneut.",
1100+ err_signup_too_many_attempts: "Zu viele falsche Schlüssel von dieser Verbindung. Versuch es morgen erneut.",
1036 err_password_mismatch: "Die Bestätigung stimmt nicht überein.",1101 err_password_mismatch: "Die Bestätigung stimmt nicht überein.",
1037 err_password_wrong: "Aktuelles Passwort ist falsch.",1102 err_password_wrong: "Aktuelles Passwort ist falsch.",
1038 invite_unknown_pat: "Kein Konto „{}“.",1103 invite_unknown_pat: "Kein Konto „{}“.",
@@ -1275,10 +1340,26 @@ pub static PT_BR: Tr = Tr {
1275 toast_team_renamed: "Equipe renomeada",1340 toast_team_renamed: "Equipe renomeada",
1276 toast_joined_pat: "Você entrou em {}",1341 toast_joined_pat: "Você entrou em {}",
1277 toast_team_left: "Você saiu da equipe",1342 toast_team_left: "Você saiu da equipe",
1278- err_team_name_taken: "Esse nome de equipe já está em uso",
1279 err_team_name_empty: "O nome da equipe não pode ficar vazio",1343 err_team_name_empty: "O nome da equipe não pode ficar vazio",
1280 err_last_team: "Você não pode sair da sua última equipe",1344 err_last_team: "Você não pode sair da sua última equipe",
1281 err_password_short: "A nova senha deve ter pelo menos 8 caracteres.",1345 err_password_short: "A nova senha deve ter pelo menos 8 caracteres.",
1346+ signup_title: "Criar uma conta",
1347+ signup_intro: "Escolha um usuário e uma senha, indique sua equipe e informe a chave de inscrição.",
1348+ signup_key_label: "Chave de inscrição",
1349+ signup_key_hint: "Peça a alguém que já usa o Ex Ego.",
1350+ signup_submit: "Criar a conta",
1351+ signup_link: "Criar uma conta",
1352+ signup_have_account: "Já tenho uma conta",
1353+ err_signup_key: "Chave de inscrição inválida.",
1354+ err_signup_username_taken: "Esse usuário já existe.",
1355+ err_signup_username_invalid: "Usuário inválido: no máximo 32 caracteres, sem espaços.",
1356+ signup_team_label: "Equipe inicial",
1357+ signup_team_hint: "O nome de uma equipe existente para entrar nela, ou um nome novo para criar a sua. Se várias equipes tiverem o mesmo nome, cole o identificador.",
1358+ err_signup_team_empty: "Informe uma equipe inicial.",
1359+ err_signup_team_ambiguous: "Várias equipes têm esse nome: use o identificador da que você quer entrar.",
1360+ err_signup_team_unknown: "Nenhuma equipe tem esse identificador.",
1361+ err_signup_rate_limited: "Contas demais criadas desta conexão. Tente de novo daqui a uma hora.",
1362+ err_signup_too_many_attempts: "Chaves incorretas demais desta conexão. Tente de novo amanhã.",
1282 err_password_mismatch: "A confirmação não confere.",1363 err_password_mismatch: "A confirmação não confere.",
1283 err_password_wrong: "Senha atual incorreta.",1364 err_password_wrong: "Senha atual incorreta.",
1284 invite_unknown_pat: "Nenhuma conta “{}”.",1365 invite_unknown_pat: "Nenhuma conta “{}”.",
modified src/main.rs +4 -0
@@ -5,6 +5,7 @@ mod config;
55 mod db;
66 mod error;
77 mod period;
8+mod ratelimit;
89 mod render;
910 mod i18n;
1011 mod routes;
@@ -131,6 +132,9 @@ async fn serve() -> Result<()> {
131132 .with_context(|| format!("bind {addr}"))?;
132133 tracing::info!("écoute sur http://{addr}");
133134
135+ // ConnectInfo : IP de repli du limiteur d'inscriptions quand aucun
136+ // en-tête de proxy n'est là (développement local).
137+ let app = app.into_make_service_with_connect_info::<std::net::SocketAddr>();
134138 axum::serve(listener, app)
135139 .with_graceful_shutdown(async {
136140 let _ = tokio::signal::ctrl_c().await;
@@ -5,6 +5,7 @@ mod config;
5 mod db;5 mod db;
6 mod error;6 mod error;
7 mod period;7 mod period;
8+mod ratelimit;
8 mod render;9 mod render;
9 mod i18n;10 mod i18n;
10 mod routes;11 mod routes;
@@ -131,6 +132,9 @@ async fn serve() -> Result<()> {
131 .with_context(|| format!("bind {addr}"))?;132 .with_context(|| format!("bind {addr}"))?;
132 tracing::info!("écoute sur http://{addr}");133 tracing::info!("écoute sur http://{addr}");
133 134
135+ // ConnectInfo : IP de repli du limiteur d'inscriptions quand aucun
136+ // en-tête de proxy n'est là (développement local).
137+ let app = app.into_make_service_with_connect_info::<std::net::SocketAddr>();
134 axum::serve(listener, app)138 axum::serve(listener, app)
135 .with_graceful_shutdown(async {139 .with_graceful_shutdown(async {
136 let _ = tokio::signal::ctrl_c().await;140 let _ = tokio::signal::ctrl_c().await;
added src/ratelimit.rs +295 -0
new file mode 100644
@@ -0,0 +1,295 @@
1+//! Fenêtres glissantes EN MÉMOIRE, par adresse IP.
2+//!
3+//! Volontairement sans table : les compteurs vivent dans l'instance, repartent
4+//! à zéro au déploiement et ne sont pas partagés entre instances. C'est un
5+//! frein à l'abus, pas un quota comptable.
6+//!
7+//! Deux quotas distincts sur l'inscription :
8+//! - `Quota::signups()` — 20 comptes CRÉÉS par heure. Un identifiant déjà pris
9+//! ou une équipe ambiguë ne consomment rien, seule une création compte.
10+//! - `Quota::bad_keys()` — 10 clés FAUSSES par jour, le frein anti-force brute
11+//! sur la clé d'inscription.
12+
13+use std::collections::HashMap;
14+use std::net::SocketAddr;
15+use std::sync::{Arc, Mutex};
16+use std::time::{Duration, Instant};
17+
18+use axum::http::HeaderMap;
19+
20+const HOUR: Duration = Duration::from_secs(3600);
21+const DAY: Duration = Duration::from_secs(24 * 3600);
22+
23+/// Garde-fou mémoire : `X-Forwarded-For` est fourni par le client, donc le
24+/// nombre de clés est contrôlable de l'extérieur. Au-delà, on purge les IP
25+/// dont le dernier passage est le plus ancien.
26+const MAX_TRACKED: usize = 10_000;
27+
28+/// Un quota « N passages par fenêtre glissante, par IP ».
29+#[derive(Clone)]
30+pub struct Quota {
31+ hits: Arc<Mutex<HashMap<String, Vec<Instant>>>>,
32+ max: usize,
33+ window: Duration,
34+}
35+
36+impl Quota {
37+ pub fn new(max: usize, window: Duration) -> Self {
38+ Self {
39+ hits: Arc::new(Mutex::new(HashMap::new())),
40+ max,
41+ window,
42+ }
43+ }
44+
45+ /// 20 comptes créés par heure.
46+ pub fn signups() -> Self {
47+ Self::new(20, HOUR)
48+ }
49+
50+ /// 10 clés d'inscription fausses par jour.
51+ pub fn bad_keys() -> Self {
52+ Self::new(10, DAY)
53+ }
54+
55+ /// L'IP a-t-elle encore du quota ? N'enregistre rien.
56+ pub fn allows(&self, ip: &str) -> bool {
57+ self.allows_at(ip, Instant::now())
58+ }
59+
60+ pub fn record(&self, ip: &str) {
61+ self.record_at(ip, Instant::now())
62+ }
63+
64+ fn allows_at(&self, ip: &str, now: Instant) -> bool {
65+ let mut map = self.hits.lock().expect("quota empoisonné");
66+ match map.get_mut(ip) {
67+ Some(hits) => {
68+ prune(hits, now, self.window);
69+ let allowed = hits.len() < self.max;
70+ if hits.is_empty() {
71+ map.remove(ip);
72+ }
73+ allowed
74+ }
75+ None => true,
76+ }
77+ }
78+
79+ fn record_at(&self, ip: &str, now: Instant) {
80+ let mut map = self.hits.lock().expect("quota empoisonné");
81+ let hits = map.entry(ip.to_string()).or_default();
82+ prune(hits, now, self.window);
83+ hits.push(now);
84+ if map.len() > MAX_TRACKED {
85+ evict(&mut map, now, self.window);
86+ }
87+ }
88+}
89+
90+/// Jette les passages sortis de la fenêtre. Les instants sont poussés dans
91+/// l'ordre, donc le vecteur est trié : une coupe au premier survivant suffit.
92+fn prune(hits: &mut Vec<Instant>, now: Instant, window: Duration) {
93+ match now.checked_sub(window) {
94+ Some(cutoff) => {
95+ let keep = hits.partition_point(|t| *t <= cutoff);
96+ hits.drain(..keep);
97+ }
98+ // Uptime plus court que la fenêtre : rien n'a pu en sortir.
99+ None => {}
100+ }
101+}
102+
103+/// Purge d'abord les IP entièrement périmées ; si ça ne suffit pas, sacrifie
104+/// les plus anciennes (elles repartent avec un quota neuf — la mémoire prime).
105+fn evict(map: &mut HashMap<String, Vec<Instant>>, now: Instant, window: Duration) {
106+ map.retain(|_, hits| {
107+ prune(hits, now, window);
108+ !hits.is_empty()
109+ });
110+ if map.len() <= MAX_TRACKED {
111+ return;
112+ }
113+ let mut last: Vec<(String, Instant)> = map
114+ .iter()
115+ .filter_map(|(ip, hits)| hits.last().map(|t| (ip.clone(), *t)))
116+ .collect();
117+ last.sort_by_key(|(_, t)| *t);
118+ for (ip, _) in last.into_iter().take(map.len() - MAX_TRACKED) {
119+ map.remove(&ip);
120+ }
121+}
122+
123+/// IP du client : `X-Forwarded-For` puis `Forwarded` (RFC 7239), sinon la
124+/// socket. Derrière le reverse proxy de Clever Cloud, c'est toujours le premier
125+/// en-tête ; en local il n'y en a aucun.
126+///
127+/// ⚠ Ces en-têtes sont déclaratifs : un client direct peut les forger. Ils ne
128+/// font foi que parce qu'un proxy de confiance les réécrit.
129+pub fn client_ip(headers: &HeaderMap, peer: Option<SocketAddr>) -> String {
130+ if let Some(ip) = headers
131+ .get("x-forwarded-for")
132+ .and_then(|v| v.to_str().ok())
133+ .and_then(|v| v.split(',').next())
134+ .map(str::trim)
135+ .filter(|v| !v.is_empty())
136+ {
137+ return normalize(ip);
138+ }
139+ if let Some(ip) = headers
140+ .get("forwarded")
141+ .and_then(|v| v.to_str().ok())
142+ .and_then(first_for)
143+ {
144+ return normalize(&ip);
145+ }
146+ peer.map(|p| p.ip().to_string())
147+ .unwrap_or_else(|| "-".to_string())
148+}
149+
150+/// Premier `for=` d'un en-tête `Forwarded` : `for=192.0.2.1;proto=https, for=…`
151+fn first_for(value: &str) -> Option<String> {
152+ value
153+ .split(',')
154+ .next()?
155+ .split(';')
156+ .filter_map(|p| {
157+ let (k, v) = p.split_once('=')?;
158+ k.trim().eq_ignore_ascii_case("for").then(|| v.trim())
159+ })
160+ .next()
161+ .map(|v| v.trim_matches('"').to_string())
162+}
163+
164+/// `"[2001:db8::1]:443"` → `2001:db8::1`, `"192.0.2.1:443"` → `192.0.2.1`.
165+/// Le port ne fait pas partie de l'identité du client.
166+fn normalize(raw: &str) -> String {
167+ let raw = raw.trim().trim_matches('"');
168+ if let Some(rest) = raw.strip_prefix('[') {
169+ return rest.split(']').next().unwrap_or(rest).to_string();
170+ }
171+ // Un seul « : » = IPv4 avec port ; plusieurs = IPv6 nu, qu'on garde entier.
172+ match (raw.matches(':').count(), raw.rsplit_once(':')) {
173+ (1, Some((host, _))) => host.to_string(),
174+ _ => raw.to_string(),
175+ }
176+}
177+
178+#[cfg(test)]
179+mod tests {
180+ use super::*;
181+
182+ fn hdr(pairs: &[(&str, &str)]) -> HeaderMap {
183+ let mut h = HeaderMap::new();
184+ for (k, v) in pairs {
185+ let name = axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap();
186+ h.insert(name, v.parse().unwrap());
187+ }
188+ h
189+ }
190+
191+ #[test]
192+ fn x_forwarded_for_wins_and_keeps_the_first_hop() {
193+ let h = hdr(&[
194+ ("x-forwarded-for", "203.0.113.7, 70.41.3.18"),
195+ ("forwarded", "for=198.51.100.9"),
196+ ]);
197+ assert_eq!(client_ip(&h, None), "203.0.113.7");
198+ }
199+
200+ #[test]
201+ fn forwarded_is_the_fallback() {
202+ let h = hdr(&[("forwarded", "proto=https;for=198.51.100.9, for=70.41.3.18")]);
203+ assert_eq!(client_ip(&h, None), "198.51.100.9");
204+ let h = hdr(&[("forwarded", "For=\"[2001:db8::1]:443\"")]);
205+ assert_eq!(client_ip(&h, None), "2001:db8::1");
206+ }
207+
208+ #[test]
209+ fn falls_back_to_the_socket_then_to_a_shared_bucket() {
210+ let peer: SocketAddr = "192.0.2.4:51000".parse().unwrap();
211+ assert_eq!(client_ip(&HeaderMap::new(), Some(peer)), "192.0.2.4");
212+ assert_eq!(client_ip(&HeaderMap::new(), None), "-");
213+ }
214+
215+ /// Un en-tête vide ne doit pas créer une clé « tout le monde ensemble »
216+ /// silencieuse : on retombe sur la socket.
217+ #[test]
218+ fn empty_header_is_ignored() {
219+ let peer: SocketAddr = "192.0.2.4:51000".parse().unwrap();
220+ let h = hdr(&[("x-forwarded-for", "")]);
221+ assert_eq!(client_ip(&h, Some(peer)), "192.0.2.4");
222+ }
223+
224+ #[test]
225+ fn twenty_creations_per_hour_then_refusal() {
226+ let q = Quota::signups();
227+ let t0 = Instant::now();
228+ for i in 0..20 {
229+ assert!(q.allows_at("ip", t0), "refus prématuré au {i}e");
230+ q.record_at("ip", t0);
231+ }
232+ assert!(!q.allows_at("ip", t0), "le 21e aurait dû être refusé");
233+ }
234+
235+ #[test]
236+ fn ten_bad_keys_per_day_then_refusal() {
237+ let q = Quota::bad_keys();
238+ let t0 = Instant::now();
239+ for i in 0..10 {
240+ assert!(q.allows_at("ip", t0), "refus prématuré au {i}e");
241+ // Étalé sur la journée : tout reste dans la fenêtre.
242+ q.record_at("ip", t0 + Duration::from_secs(3600 * i as u64));
243+ }
244+ let end_of_day = t0 + Duration::from_secs(3600 * 23);
245+ assert!(!q.allows_at("ip", end_of_day), "le 11e aurait dû être refusé");
246+ // Le lendemain, la première tentative est sortie de la fenêtre.
247+ assert!(q.allows_at("ip", t0 + DAY + Duration::from_secs(1)));
248+ }
249+
250+ /// Les deux quotas sont indépendants : épuiser les clés fausses ne doit
251+ /// pas entamer le quota de créations, et réciproquement.
252+ #[test]
253+ fn the_two_quotas_are_independent() {
254+ let creations = Quota::signups();
255+ let bad = Quota::bad_keys();
256+ let t0 = Instant::now();
257+ for _ in 0..10 {
258+ bad.record_at("ip", t0);
259+ }
260+ assert!(!bad.allows_at("ip", t0));
261+ assert!(creations.allows_at("ip", t0));
262+ }
263+
264+ #[test]
265+ fn the_window_slides() {
266+ let q = Quota::signups();
267+ let t0 = Instant::now();
268+ for i in 0..20 {
269+ // Un compte par minute : la fenêtre se vide dans le même ordre.
270+ q.record_at("ip", t0 + Duration::from_secs(60 * i as u64));
271+ }
272+ let full = t0 + Duration::from_secs(60 * 19);
273+ assert!(!q.allows_at("ip", full));
274+ // Une heure après le tout premier, il ressort de la fenêtre : une place.
275+ assert!(q.allows_at("ip", t0 + HOUR + Duration::from_secs(1)));
276+ // Deux heures après, plus rien ne compte.
277+ assert!(q.allows_at("ip", t0 + HOUR * 2));
278+ assert_eq!(
279+ q.hits.lock().unwrap().len(),
280+ 0,
281+ "l'entrée vide doit disparaître"
282+ );
283+ }
284+
285+ #[test]
286+ fn quotas_are_per_ip() {
287+ let q = Quota::signups();
288+ let t0 = Instant::now();
289+ for _ in 0..20 {
290+ q.record_at("a", t0);
291+ }
292+ assert!(!q.allows_at("a", t0));
293+ assert!(q.allows_at("b", t0));
294+ }
295+}
new file mode 100644
@@ -0,0 +1,295 @@
1+//! Fenêtres glissantes EN MÉMOIRE, par adresse IP.
2+//!
3+//! Volontairement sans table : les compteurs vivent dans l'instance, repartent
4+//! à zéro au déploiement et ne sont pas partagés entre instances. C'est un
5+//! frein à l'abus, pas un quota comptable.
6+//!
7+//! Deux quotas distincts sur l'inscription :
8+//! - `Quota::signups()` — 20 comptes CRÉÉS par heure. Un identifiant déjà pris
9+//! ou une équipe ambiguë ne consomment rien, seule une création compte.
10+//! - `Quota::bad_keys()` — 10 clés FAUSSES par jour, le frein anti-force brute
11+//! sur la clé d'inscription.
12+
13+use std::collections::HashMap;
14+use std::net::SocketAddr;
15+use std::sync::{Arc, Mutex};
16+use std::time::{Duration, Instant};
17+
18+use axum::http::HeaderMap;
19+
20+const HOUR: Duration = Duration::from_secs(3600);
21+const DAY: Duration = Duration::from_secs(24 * 3600);
22+
23+/// Garde-fou mémoire : `X-Forwarded-For` est fourni par le client, donc le
24+/// nombre de clés est contrôlable de l'extérieur. Au-delà, on purge les IP
25+/// dont le dernier passage est le plus ancien.
26+const MAX_TRACKED: usize = 10_000;
27+
28+/// Un quota « N passages par fenêtre glissante, par IP ».
29+#[derive(Clone)]
30+pub struct Quota {
31+ hits: Arc<Mutex<HashMap<String, Vec<Instant>>>>,
32+ max: usize,
33+ window: Duration,
34+}
35+
36+impl Quota {
37+ pub fn new(max: usize, window: Duration) -> Self {
38+ Self {
39+ hits: Arc::new(Mutex::new(HashMap::new())),
40+ max,
41+ window,
42+ }
43+ }
44+
45+ /// 20 comptes créés par heure.
46+ pub fn signups() -> Self {
47+ Self::new(20, HOUR)
48+ }
49+
50+ /// 10 clés d'inscription fausses par jour.
51+ pub fn bad_keys() -> Self {
52+ Self::new(10, DAY)
53+ }
54+
55+ /// L'IP a-t-elle encore du quota ? N'enregistre rien.
56+ pub fn allows(&self, ip: &str) -> bool {
57+ self.allows_at(ip, Instant::now())
58+ }
59+
60+ pub fn record(&self, ip: &str) {
61+ self.record_at(ip, Instant::now())
62+ }
63+
64+ fn allows_at(&self, ip: &str, now: Instant) -> bool {
65+ let mut map = self.hits.lock().expect("quota empoisonné");
66+ match map.get_mut(ip) {
67+ Some(hits) => {
68+ prune(hits, now, self.window);
69+ let allowed = hits.len() < self.max;
70+ if hits.is_empty() {
71+ map.remove(ip);
72+ }
73+ allowed
74+ }
75+ None => true,
76+ }
77+ }
78+
79+ fn record_at(&self, ip: &str, now: Instant) {
80+ let mut map = self.hits.lock().expect("quota empoisonné");
81+ let hits = map.entry(ip.to_string()).or_default();
82+ prune(hits, now, self.window);
83+ hits.push(now);
84+ if map.len() > MAX_TRACKED {
85+ evict(&mut map, now, self.window);
86+ }
87+ }
88+}
89+
90+/// Jette les passages sortis de la fenêtre. Les instants sont poussés dans
91+/// l'ordre, donc le vecteur est trié : une coupe au premier survivant suffit.
92+fn prune(hits: &mut Vec<Instant>, now: Instant, window: Duration) {
93+ match now.checked_sub(window) {
94+ Some(cutoff) => {
95+ let keep = hits.partition_point(|t| *t <= cutoff);
96+ hits.drain(..keep);
97+ }
98+ // Uptime plus court que la fenêtre : rien n'a pu en sortir.
99+ None => {}
100+ }
101+}
102+
103+/// Purge d'abord les IP entièrement périmées ; si ça ne suffit pas, sacrifie
104+/// les plus anciennes (elles repartent avec un quota neuf — la mémoire prime).
105+fn evict(map: &mut HashMap<String, Vec<Instant>>, now: Instant, window: Duration) {
106+ map.retain(|_, hits| {
107+ prune(hits, now, window);
108+ !hits.is_empty()
109+ });
110+ if map.len() <= MAX_TRACKED {
111+ return;
112+ }
113+ let mut last: Vec<(String, Instant)> = map
114+ .iter()
115+ .filter_map(|(ip, hits)| hits.last().map(|t| (ip.clone(), *t)))
116+ .collect();
117+ last.sort_by_key(|(_, t)| *t);
118+ for (ip, _) in last.into_iter().take(map.len() - MAX_TRACKED) {
119+ map.remove(&ip);
120+ }
121+}
122+
123+/// IP du client : `X-Forwarded-For` puis `Forwarded` (RFC 7239), sinon la
124+/// socket. Derrière le reverse proxy de Clever Cloud, c'est toujours le premier
125+/// en-tête ; en local il n'y en a aucun.
126+///
127+/// ⚠ Ces en-têtes sont déclaratifs : un client direct peut les forger. Ils ne
128+/// font foi que parce qu'un proxy de confiance les réécrit.
129+pub fn client_ip(headers: &HeaderMap, peer: Option<SocketAddr>) -> String {
130+ if let Some(ip) = headers
131+ .get("x-forwarded-for")
132+ .and_then(|v| v.to_str().ok())
133+ .and_then(|v| v.split(',').next())
134+ .map(str::trim)
135+ .filter(|v| !v.is_empty())
136+ {
137+ return normalize(ip);
138+ }
139+ if let Some(ip) = headers
140+ .get("forwarded")
141+ .and_then(|v| v.to_str().ok())
142+ .and_then(first_for)
143+ {
144+ return normalize(&ip);
145+ }
146+ peer.map(|p| p.ip().to_string())
147+ .unwrap_or_else(|| "-".to_string())
148+}
149+
150+/// Premier `for=` d'un en-tête `Forwarded` : `for=192.0.2.1;proto=https, for=…`
151+fn first_for(value: &str) -> Option<String> {
152+ value
153+ .split(',')
154+ .next()?
155+ .split(';')
156+ .filter_map(|p| {
157+ let (k, v) = p.split_once('=')?;
158+ k.trim().eq_ignore_ascii_case("for").then(|| v.trim())
159+ })
160+ .next()
161+ .map(|v| v.trim_matches('"').to_string())
162+}
163+
164+/// `"[2001:db8::1]:443"` → `2001:db8::1`, `"192.0.2.1:443"` → `192.0.2.1`.
165+/// Le port ne fait pas partie de l'identité du client.
166+fn normalize(raw: &str) -> String {
167+ let raw = raw.trim().trim_matches('"');
168+ if let Some(rest) = raw.strip_prefix('[') {
169+ return rest.split(']').next().unwrap_or(rest).to_string();
170+ }
171+ // Un seul « : » = IPv4 avec port ; plusieurs = IPv6 nu, qu'on garde entier.
172+ match (raw.matches(':').count(), raw.rsplit_once(':')) {
173+ (1, Some((host, _))) => host.to_string(),
174+ _ => raw.to_string(),
175+ }
176+}
177+
178+#[cfg(test)]
179+mod tests {
180+ use super::*;
181+
182+ fn hdr(pairs: &[(&str, &str)]) -> HeaderMap {
183+ let mut h = HeaderMap::new();
184+ for (k, v) in pairs {
185+ let name = axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap();
186+ h.insert(name, v.parse().unwrap());
187+ }
188+ h
189+ }
190+
191+ #[test]
192+ fn x_forwarded_for_wins_and_keeps_the_first_hop() {
193+ let h = hdr(&[
194+ ("x-forwarded-for", "203.0.113.7, 70.41.3.18"),
195+ ("forwarded", "for=198.51.100.9"),
196+ ]);
197+ assert_eq!(client_ip(&h, None), "203.0.113.7");
198+ }
199+
200+ #[test]
201+ fn forwarded_is_the_fallback() {
202+ let h = hdr(&[("forwarded", "proto=https;for=198.51.100.9, for=70.41.3.18")]);
203+ assert_eq!(client_ip(&h, None), "198.51.100.9");
204+ let h = hdr(&[("forwarded", "For=\"[2001:db8::1]:443\"")]);
205+ assert_eq!(client_ip(&h, None), "2001:db8::1");
206+ }
207+
208+ #[test]
209+ fn falls_back_to_the_socket_then_to_a_shared_bucket() {
210+ let peer: SocketAddr = "192.0.2.4:51000".parse().unwrap();
211+ assert_eq!(client_ip(&HeaderMap::new(), Some(peer)), "192.0.2.4");
212+ assert_eq!(client_ip(&HeaderMap::new(), None), "-");
213+ }
214+
215+ /// Un en-tête vide ne doit pas créer une clé « tout le monde ensemble »
216+ /// silencieuse : on retombe sur la socket.
217+ #[test]
218+ fn empty_header_is_ignored() {
219+ let peer: SocketAddr = "192.0.2.4:51000".parse().unwrap();
220+ let h = hdr(&[("x-forwarded-for", "")]);
221+ assert_eq!(client_ip(&h, Some(peer)), "192.0.2.4");
222+ }
223+
224+ #[test]
225+ fn twenty_creations_per_hour_then_refusal() {
226+ let q = Quota::signups();
227+ let t0 = Instant::now();
228+ for i in 0..20 {
229+ assert!(q.allows_at("ip", t0), "refus prématuré au {i}e");
230+ q.record_at("ip", t0);
231+ }
232+ assert!(!q.allows_at("ip", t0), "le 21e aurait dû être refusé");
233+ }
234+
235+ #[test]
236+ fn ten_bad_keys_per_day_then_refusal() {
237+ let q = Quota::bad_keys();
238+ let t0 = Instant::now();
239+ for i in 0..10 {
240+ assert!(q.allows_at("ip", t0), "refus prématuré au {i}e");
241+ // Étalé sur la journée : tout reste dans la fenêtre.
242+ q.record_at("ip", t0 + Duration::from_secs(3600 * i as u64));
243+ }
244+ let end_of_day = t0 + Duration::from_secs(3600 * 23);
245+ assert!(!q.allows_at("ip", end_of_day), "le 11e aurait dû être refusé");
246+ // Le lendemain, la première tentative est sortie de la fenêtre.
247+ assert!(q.allows_at("ip", t0 + DAY + Duration::from_secs(1)));
248+ }
249+
250+ /// Les deux quotas sont indépendants : épuiser les clés fausses ne doit
251+ /// pas entamer le quota de créations, et réciproquement.
252+ #[test]
253+ fn the_two_quotas_are_independent() {
254+ let creations = Quota::signups();
255+ let bad = Quota::bad_keys();
256+ let t0 = Instant::now();
257+ for _ in 0..10 {
258+ bad.record_at("ip", t0);
259+ }
260+ assert!(!bad.allows_at("ip", t0));
261+ assert!(creations.allows_at("ip", t0));
262+ }
263+
264+ #[test]
265+ fn the_window_slides() {
266+ let q = Quota::signups();
267+ let t0 = Instant::now();
268+ for i in 0..20 {
269+ // Un compte par minute : la fenêtre se vide dans le même ordre.
270+ q.record_at("ip", t0 + Duration::from_secs(60 * i as u64));
271+ }
272+ let full = t0 + Duration::from_secs(60 * 19);
273+ assert!(!q.allows_at("ip", full));
274+ // Une heure après le tout premier, il ressort de la fenêtre : une place.
275+ assert!(q.allows_at("ip", t0 + HOUR + Duration::from_secs(1)));
276+ // Deux heures après, plus rien ne compte.
277+ assert!(q.allows_at("ip", t0 + HOUR * 2));
278+ assert_eq!(
279+ q.hits.lock().unwrap().len(),
280+ 0,
281+ "l'entrée vide doit disparaître"
282+ );
283+ }
284+
285+ #[test]
286+ fn quotas_are_per_ip() {
287+ let q = Quota::signups();
288+ let t0 = Instant::now();
289+ for _ in 0..20 {
290+ q.record_at("a", t0);
291+ }
292+ assert!(!q.allows_at("a", t0));
293+ assert!(q.allows_at("b", t0));
294+ }
295+}
modified src/routes/login.rs +14 -4
@@ -20,6 +20,8 @@ pub struct LoginPage {
2020 /// Page anonyme : langue par défaut de l'instance (DEFAULT_LANG).
2121 pub tr: &'static crate::i18n::Tr,
2222 pub lang_code: &'static str,
23+ /// SIGNUP_KEY posée : on affiche le lien « Créer un compte ».
24+ pub signup: bool,
2325 }
2426
2527 #[derive(Deserialize)]
@@ -37,6 +39,7 @@ pub async fn login_page(user: Option<AuthUser>, Query(q): Query<LoginQuery>) ->
3739 error: q.err.is_some(),
3840 tr: lang.tr(),
3941 lang_code: lang.code(),
42+ signup: crate::config::signup_key().is_some(),
4043 })
4144 .into_response()
4245 }
@@ -62,8 +65,15 @@ pub async fn login_submit(
6265 return Ok(Redirect::to("/login?err=1").into_response());
6366 }
6467
65- let token = token::mint(&state.root, creds.id).map_err(AppError::Internal)?;
66- let cookie = Cookie::build((SESSION_COOKIE, token))
68+ let cookie = session_cookie(&state, creds.id)?;
69+ Ok((jar.add(cookie), Redirect::to("/")).into_response())
70+}
71+
72+/// Cookie de session d'un compte qui vient de s'authentifier — partagé par la
73+/// connexion et l'inscription (qui connecte directement : pas de confirmation).
74+pub fn session_cookie(state: &AppState, user_id: uuid::Uuid) -> Result<Cookie<'static>, AppError> {
75+ let token = token::mint(&state.root, user_id).map_err(AppError::Internal)?;
76+ Ok(Cookie::build((SESSION_COOKIE, token))
6777 .path("/")
6878 .http_only(true)
6979 .secure(true)
@@ -71,8 +81,8 @@ pub async fn login_submit(
7181 // Chrome retient les cookies Strict → re-login à chaque ouverture.
7282 // Les POST restent protégés par le token CSRF.
7383 .same_site(SameSite::Lax)
74- .max_age(time::Duration::seconds(SESSION_SECONDS));
75- Ok((jar.add(cookie), Redirect::to("/")).into_response())
84+ .max_age(time::Duration::seconds(SESSION_SECONDS))
85+ .build())
7686 }
7787
7888 /// Hash argon2id factice — uniquement pour égaliser le temps de réponse.
@@ -20,6 +20,8 @@ pub struct LoginPage {
20 /// Page anonyme : langue par défaut de l'instance (DEFAULT_LANG).20 /// Page anonyme : langue par défaut de l'instance (DEFAULT_LANG).
21 pub tr: &'static crate::i18n::Tr,21 pub tr: &'static crate::i18n::Tr,
22 pub lang_code: &'static str,22 pub lang_code: &'static str,
23+ /// SIGNUP_KEY posée : on affiche le lien « Créer un compte ».
24+ pub signup: bool,
23 }25 }
24 26
25 #[derive(Deserialize)]27 #[derive(Deserialize)]
@@ -37,6 +39,7 @@ pub async fn login_page(user: Option<AuthUser>, Query(q): Query<LoginQuery>) ->
37 error: q.err.is_some(),39 error: q.err.is_some(),
38 tr: lang.tr(),40 tr: lang.tr(),
39 lang_code: lang.code(),41 lang_code: lang.code(),
42+ signup: crate::config::signup_key().is_some(),
40 })43 })
41 .into_response()44 .into_response()
42 }45 }
@@ -62,8 +65,15 @@ pub async fn login_submit(
62 return Ok(Redirect::to("/login?err=1").into_response());65 return Ok(Redirect::to("/login?err=1").into_response());
63 }66 }
64 67
65- let token = token::mint(&state.root, creds.id).map_err(AppError::Internal)?;68+ let cookie = session_cookie(&state, creds.id)?;
66- let cookie = Cookie::build((SESSION_COOKIE, token))69+ Ok((jar.add(cookie), Redirect::to("/")).into_response())
70+}
71+
72+/// Cookie de session d'un compte qui vient de s'authentifier — partagé par la
73+/// connexion et l'inscription (qui connecte directement : pas de confirmation).
74+pub fn session_cookie(state: &AppState, user_id: uuid::Uuid) -> Result<Cookie<'static>, AppError> {
75+ let token = token::mint(&state.root, user_id).map_err(AppError::Internal)?;
76+ Ok(Cookie::build((SESSION_COOKIE, token))
67 .path("/")77 .path("/")
68 .http_only(true)78 .http_only(true)
69 .secure(true)79 .secure(true)
@@ -71,8 +81,8 @@ pub async fn login_submit(
71 // Chrome retient les cookies Strict → re-login à chaque ouverture.81 // Chrome retient les cookies Strict → re-login à chaque ouverture.
72 // Les POST restent protégés par le token CSRF.82 // Les POST restent protégés par le token CSRF.
73 .same_site(SameSite::Lax)83 .same_site(SameSite::Lax)
74- .max_age(time::Duration::seconds(SESSION_SECONDS));84+ .max_age(time::Duration::seconds(SESSION_SECONDS))
75- Ok((jar.add(cookie), Redirect::to("/")).into_response())85+ .build())
76 }86 }
77 87
78 /// Hash argon2id factice — uniquement pour égaliser le temps de réponse.88 /// Hash argon2id factice — uniquement pour égaliser le temps de réponse.
modified src/routes/mod.rs +7 -1
@@ -1,6 +1,7 @@
11 mod entries;
2-mod login;
2+pub(crate) mod login;
33 mod settings;
4+mod signup;
45 mod statics;
56 mod stats;
67 mod teams;
@@ -27,6 +28,11 @@ pub fn router(state: AppState) -> Router {
2728 .route("/", get(root))
2829 .route("/login", get(login::login_page).post(login::login_submit))
2930 .route("/logout", post(login::logout))
31+ // 404 tant que SIGNUP_KEY n'est pas posée (cf. routes/signup.rs).
32+ .route(
33+ "/inscription",
34+ get(signup::signup_page).post(signup::signup_submit),
35+ )
3036 .route(
3137 "/temps",
3238 get(entries::entries_list).post(entries::entry_create),
@@ -1,6 +1,7 @@
1 mod entries;1 mod entries;
2-mod login;2+pub(crate) mod login;
3 mod settings;3 mod settings;
4+mod signup;
4 mod statics;5 mod statics;
5 mod stats;6 mod stats;
6 mod teams;7 mod teams;
@@ -27,6 +28,11 @@ pub fn router(state: AppState) -> Router {
27 .route("/", get(root))28 .route("/", get(root))
28 .route("/login", get(login::login_page).post(login::login_submit))29 .route("/login", get(login::login_page).post(login::login_submit))
29 .route("/logout", post(login::logout))30 .route("/logout", post(login::logout))
31+ // 404 tant que SIGNUP_KEY n'est pas posée (cf. routes/signup.rs).
32+ .route(
33+ "/inscription",
34+ get(signup::signup_page).post(signup::signup_submit),
35+ )
30 .route(36 .route(
31 "/temps",37 "/temps",
32 get(entries::entries_list).post(entries::entry_create),38 get(entries::entries_list).post(entries::entry_create),
modified src/routes/settings.rs +0 -1
@@ -82,7 +82,6 @@ pub async fn settings_page(
8282 Some("affichage") => Some(tr.toast_theme_updated.to_string()),
8383 Some("graphiques") => Some(tr.toast_chart_style_updated.to_string()),
8484 _ => match q.err.as_deref() {
85- Some("eq-doublon") => Some(tr.err_team_name_taken.to_string()),
8685 Some("eq-vide") => Some(tr.err_team_name_empty.to_string()),
8786 Some("derniere") => Some(tr.err_last_team.to_string()),
8887 _ => None,
@@ -82,7 +82,6 @@ pub async fn settings_page(
82 Some("affichage") => Some(tr.toast_theme_updated.to_string()),82 Some("affichage") => Some(tr.toast_theme_updated.to_string()),
83 Some("graphiques") => Some(tr.toast_chart_style_updated.to_string()),83 Some("graphiques") => Some(tr.toast_chart_style_updated.to_string()),
84 _ => match q.err.as_deref() {84 _ => match q.err.as_deref() {
85- Some("eq-doublon") => Some(tr.err_team_name_taken.to_string()),
86 Some("eq-vide") => Some(tr.err_team_name_empty.to_string()),85 Some("eq-vide") => Some(tr.err_team_name_empty.to_string()),
87 Some("derniere") => Some(tr.err_last_team.to_string()),86 Some("derniere") => Some(tr.err_last_team.to_string()),
88 _ => None,87 _ => None,
added src/routes/signup.rs +204 -0
new file mode 100644
@@ -0,0 +1,204 @@
1+//! Inscription libre-service, ouverte UNIQUEMENT si `SIGNUP_KEY` est posée.
2+//!
3+//! Trois champs : identifiant, mot de passe, clé. Ni e-mail ni confirmation —
4+//! la clé partagée EST le contrôle d'accès. Elle se compare en temps constant,
5+//! et rien ne touche la base tant qu'elle est fausse. L'application ne limite
6+//! pas le débit : la clé doit être longue et aléatoire.
7+
8+use std::net::SocketAddr;
9+
10+use askama::Template;
11+use axum::Form;
12+use axum::extract::{ConnectInfo, Query, State};
13+use axum::http::HeaderMap;
14+use axum::response::{IntoResponse, Redirect, Response};
15+use axum_extra::extract::CookieJar;
16+use serde::Deserialize;
17+use subtle::ConstantTimeEq;
18+
19+use crate::auth::extractor::AuthUser;
20+use crate::auth::password;
21+use crate::db::teams::{self, Resolved};
22+use crate::db::users::{self, InitialTeam, Signup};
23+use crate::error::AppError;
24+use crate::i18n::Tr;
25+use crate::ratelimit::client_ip;
26+use crate::render::HtmlTemplate;
27+use crate::routes::login::session_cookie;
28+use crate::state::AppState;
29+use crate::team::team_cookie;
30+
31+#[derive(Template)]
32+#[template(path = "signup.html")]
33+pub struct SignupPage {
34+ /// Page anonyme : langue par défaut de l'instance (DEFAULT_LANG).
35+ pub tr: &'static Tr,
36+ pub lang_code: &'static str,
37+ pub error: Option<&'static str>,
38+ /// Identifiant et équipe rejoués dans les champs après une erreur.
39+ pub username: String,
40+ pub team: String,
41+}
42+
43+#[derive(Deserialize)]
44+pub struct SignupQuery {
45+ err: Option<String>,
46+ /// Champs à rejouer (jamais le mot de passe ni la clé).
47+ nom: Option<String>,
48+ equipe: Option<String>,
49+}
50+
51+/// Clé attendue, ou 404 : sans `SIGNUP_KEY`, la route n'existe pas du tout
52+/// (ni page, ni indice de son existence).
53+fn key_or_404() -> Result<String, AppError> {
54+ crate::config::signup_key().ok_or(AppError::NotFound)
55+}
56+
57+pub async fn signup_page(
58+ user: Option<AuthUser>,
59+ Query(q): Query<SignupQuery>,
60+) -> Result<Response, AppError> {
61+ key_or_404()?;
62+ if user.is_some() {
63+ return Ok(Redirect::to("/").into_response());
64+ }
65+ let lang = crate::config::default_lang();
66+ let tr = lang.tr();
67+ Ok(HtmlTemplate(SignupPage {
68+ tr,
69+ lang_code: lang.code(),
70+ error: match q.err.as_deref() {
71+ Some("cle") => Some(tr.err_signup_key),
72+ Some("pris") => Some(tr.err_signup_username_taken),
73+ Some("nom") => Some(tr.err_signup_username_invalid),
74+ Some("court") => Some(tr.err_password_short),
75+ Some("equipe-vide") => Some(tr.err_signup_team_empty),
76+ Some("equipe-ambigue") => Some(tr.err_signup_team_ambiguous),
77+ Some("equipe-inconnue") => Some(tr.err_signup_team_unknown),
78+ Some("trop") => Some(tr.err_signup_rate_limited),
79+ Some("essais") => Some(tr.err_signup_too_many_attempts),
80+ _ => None,
81+ },
82+ username: q.nom.unwrap_or_default(),
83+ team: q.equipe.unwrap_or_default(),
84+ })
85+ .into_response())
86+}
87+
88+#[derive(Deserialize)]
89+pub struct SignupForm {
90+ username: String,
91+ password: String,
92+ cle: String,
93+ /// Équipe initiale : un nom, ou l'identifiant d'une équipe précise.
94+ equipe: String,
95+}
96+
97+/// Identifiant : non vide, ≤ 32 caractères, sans espace ni caractère de
98+/// contrôle (il s'affiche partout et sert de nom à la première équipe).
99+fn valid_username(u: &str) -> bool {
100+ !u.is_empty()
101+ && u.chars().count() <= 32
102+ && !u.chars().any(|c| c.is_whitespace() || c.is_control())
103+}
104+
105+pub async fn signup_submit(
106+ State(state): State<AppState>,
107+ headers: HeaderMap,
108+ ConnectInfo(peer): ConnectInfo<SocketAddr>,
109+ jar: CookieJar,
110+ Form(form): Form<SignupForm>,
111+) -> Result<Response, AppError> {
112+ let expected = key_or_404()?;
113+ let ip = client_ip(&headers, Some(peer));
114+
115+ // Frein anti-force brute : 10 clés fausses par jour et par IP. Le quota se
116+ // consulte AVANT la comparaison — au-delà, la clé n'est même plus testée,
117+ // sinon la limite ne ralentirait rien.
118+ if !state.bad_keys.allows(&ip) {
119+ tracing::warn!(%ip, "inscription refusée : trop de clés fausses");
120+ return Ok(back("essais", &form));
121+ }
122+ // Temps constant, et surtout : on sort AVANT toute requête SQL. Une clé
123+ // fausse ne dit rien de l'existence d'un identifiant.
124+ let ok: bool = expected.as_bytes().ct_eq(form.cle.as_bytes()).into();
125+ if !ok {
126+ state.bad_keys.record(&ip);
127+ return Ok(back("cle", &form));
128+ }
129+
130+ let username = form.username.trim().to_string();
131+ if !valid_username(&username) {
132+ return Ok(back("nom", &form));
133+ }
134+ if form.password.len() < 8 {
135+ return Ok(back("court", &form));
136+ }
137+ let wanted_team = form.equipe.trim().to_string();
138+ if wanted_team.is_empty() {
139+ return Ok(back("equipe-vide", &form));
140+ }
141+
142+ // Quota AVANT le hash argon2id (coûteux) et avant d'écrire quoi que ce soit.
143+ if !state.signups.allows(&ip) {
144+ tracing::warn!(%ip, "inscription refusée : quota horaire atteint");
145+ return Ok(back("trop", &form));
146+ }
147+
148+ let team = match teams::resolve(&state.pool, &wanted_team).await? {
149+ Resolved::One(t) => InitialTeam::Existing(t.id),
150+ Resolved::Ambiguous(_) => return Ok(back("equipe-ambigue", &form)),
151+ // Un identifiant qui ne désigne rien est une faute de frappe, pas une
152+ // intention de créer : seul un NOM libre ouvre une nouvelle équipe.
153+ Resolved::None { was_id: true } => return Ok(back("equipe-inconnue", &form)),
154+ Resolved::None { was_id: false } => InitialTeam::Create(wanted_team),
155+ };
156+
157+ let hash = password::hash(&form.password).map_err(AppError::Internal)?;
158+ let lang = crate::config::default_lang();
159+ match users::signup(&state.pool, &username, &hash, lang.code(), team).await? {
160+ Signup::UsernameTaken => Ok(back("pris", &form)),
161+ Signup::Created { user_id, team_id } => {
162+ state.signups.record(&ip);
163+ // Connexion immédiate : sans confirmation à attendre, renvoyer sur
164+ // le login ferait ressaisir ce qui vient d'être saisi.
165+ let jar = jar
166+ .add(session_cookie(&state, user_id)?)
167+ .add(team_cookie(team_id));
168+ Ok((jar, Redirect::to("/")).into_response())
169+ }
170+ }
171+}
172+
173+/// Retour au formulaire avec l'erreur et les champs non secrets rejoués.
174+fn back(err: &str, form: &SignupForm) -> Response {
175+ Redirect::to(&format!(
176+ "/inscription?err={err}&nom={}&equipe={}",
177+ crate::routes::urlenc(form.username.trim()),
178+ crate::routes::urlenc(form.equipe.trim())
179+ ))
180+ .into_response()
181+}
182+
183+#[cfg(test)]
184+mod tests {
185+ use super::valid_username;
186+
187+ #[test]
188+ fn username_rules() {
189+ for ok in ["camille", "jean-luc", "a", "élodie", "u".repeat(32).as_str()] {
190+ assert!(valid_username(ok), "« {ok} » devrait passer");
191+ }
192+ for ko in ["", "deux mots", "tab\there", "u".repeat(33).as_str()] {
193+ assert!(!valid_username(ko), "« {ko} » ne devrait pas passer");
194+ }
195+ }
196+
197+ /// La longueur se compte en CARACTÈRES : 32 accents ne doivent pas être
198+ /// refusés comme 64 octets.
199+ #[test]
200+ fn username_length_counts_chars_not_bytes() {
201+ assert!(valid_username(&"é".repeat(32)));
202+ assert!(!valid_username(&"é".repeat(33)));
203+ }
204+}
new file mode 100644
@@ -0,0 +1,204 @@
1+//! Inscription libre-service, ouverte UNIQUEMENT si `SIGNUP_KEY` est posée.
2+//!
3+//! Trois champs : identifiant, mot de passe, clé. Ni e-mail ni confirmation —
4+//! la clé partagée EST le contrôle d'accès. Elle se compare en temps constant,
5+//! et rien ne touche la base tant qu'elle est fausse. L'application ne limite
6+//! pas le débit : la clé doit être longue et aléatoire.
7+
8+use std::net::SocketAddr;
9+
10+use askama::Template;
11+use axum::Form;
12+use axum::extract::{ConnectInfo, Query, State};
13+use axum::http::HeaderMap;
14+use axum::response::{IntoResponse, Redirect, Response};
15+use axum_extra::extract::CookieJar;
16+use serde::Deserialize;
17+use subtle::ConstantTimeEq;
18+
19+use crate::auth::extractor::AuthUser;
20+use crate::auth::password;
21+use crate::db::teams::{self, Resolved};
22+use crate::db::users::{self, InitialTeam, Signup};
23+use crate::error::AppError;
24+use crate::i18n::Tr;
25+use crate::ratelimit::client_ip;
26+use crate::render::HtmlTemplate;
27+use crate::routes::login::session_cookie;
28+use crate::state::AppState;
29+use crate::team::team_cookie;
30+
31+#[derive(Template)]
32+#[template(path = "signup.html")]
33+pub struct SignupPage {
34+ /// Page anonyme : langue par défaut de l'instance (DEFAULT_LANG).
35+ pub tr: &'static Tr,
36+ pub lang_code: &'static str,
37+ pub error: Option<&'static str>,
38+ /// Identifiant et équipe rejoués dans les champs après une erreur.
39+ pub username: String,
40+ pub team: String,
41+}
42+
43+#[derive(Deserialize)]
44+pub struct SignupQuery {
45+ err: Option<String>,
46+ /// Champs à rejouer (jamais le mot de passe ni la clé).
47+ nom: Option<String>,
48+ equipe: Option<String>,
49+}
50+
51+/// Clé attendue, ou 404 : sans `SIGNUP_KEY`, la route n'existe pas du tout
52+/// (ni page, ni indice de son existence).
53+fn key_or_404() -> Result<String, AppError> {
54+ crate::config::signup_key().ok_or(AppError::NotFound)
55+}
56+
57+pub async fn signup_page(
58+ user: Option<AuthUser>,
59+ Query(q): Query<SignupQuery>,
60+) -> Result<Response, AppError> {
61+ key_or_404()?;
62+ if user.is_some() {
63+ return Ok(Redirect::to("/").into_response());
64+ }
65+ let lang = crate::config::default_lang();
66+ let tr = lang.tr();
67+ Ok(HtmlTemplate(SignupPage {
68+ tr,
69+ lang_code: lang.code(),
70+ error: match q.err.as_deref() {
71+ Some("cle") => Some(tr.err_signup_key),
72+ Some("pris") => Some(tr.err_signup_username_taken),
73+ Some("nom") => Some(tr.err_signup_username_invalid),
74+ Some("court") => Some(tr.err_password_short),
75+ Some("equipe-vide") => Some(tr.err_signup_team_empty),
76+ Some("equipe-ambigue") => Some(tr.err_signup_team_ambiguous),
77+ Some("equipe-inconnue") => Some(tr.err_signup_team_unknown),
78+ Some("trop") => Some(tr.err_signup_rate_limited),
79+ Some("essais") => Some(tr.err_signup_too_many_attempts),
80+ _ => None,
81+ },
82+ username: q.nom.unwrap_or_default(),
83+ team: q.equipe.unwrap_or_default(),
84+ })
85+ .into_response())
86+}
87+
88+#[derive(Deserialize)]
89+pub struct SignupForm {
90+ username: String,
91+ password: String,
92+ cle: String,
93+ /// Équipe initiale : un nom, ou l'identifiant d'une équipe précise.
94+ equipe: String,
95+}
96+
97+/// Identifiant : non vide, ≤ 32 caractères, sans espace ni caractère de
98+/// contrôle (il s'affiche partout et sert de nom à la première équipe).
99+fn valid_username(u: &str) -> bool {
100+ !u.is_empty()
101+ && u.chars().count() <= 32
102+ && !u.chars().any(|c| c.is_whitespace() || c.is_control())
103+}
104+
105+pub async fn signup_submit(
106+ State(state): State<AppState>,
107+ headers: HeaderMap,
108+ ConnectInfo(peer): ConnectInfo<SocketAddr>,
109+ jar: CookieJar,
110+ Form(form): Form<SignupForm>,
111+) -> Result<Response, AppError> {
112+ let expected = key_or_404()?;
113+ let ip = client_ip(&headers, Some(peer));
114+
115+ // Frein anti-force brute : 10 clés fausses par jour et par IP. Le quota se
116+ // consulte AVANT la comparaison — au-delà, la clé n'est même plus testée,
117+ // sinon la limite ne ralentirait rien.
118+ if !state.bad_keys.allows(&ip) {
119+ tracing::warn!(%ip, "inscription refusée : trop de clés fausses");
120+ return Ok(back("essais", &form));
121+ }
122+ // Temps constant, et surtout : on sort AVANT toute requête SQL. Une clé
123+ // fausse ne dit rien de l'existence d'un identifiant.
124+ let ok: bool = expected.as_bytes().ct_eq(form.cle.as_bytes()).into();
125+ if !ok {
126+ state.bad_keys.record(&ip);
127+ return Ok(back("cle", &form));
128+ }
129+
130+ let username = form.username.trim().to_string();
131+ if !valid_username(&username) {
132+ return Ok(back("nom", &form));
133+ }
134+ if form.password.len() < 8 {
135+ return Ok(back("court", &form));
136+ }
137+ let wanted_team = form.equipe.trim().to_string();
138+ if wanted_team.is_empty() {
139+ return Ok(back("equipe-vide", &form));
140+ }
141+
142+ // Quota AVANT le hash argon2id (coûteux) et avant d'écrire quoi que ce soit.
143+ if !state.signups.allows(&ip) {
144+ tracing::warn!(%ip, "inscription refusée : quota horaire atteint");
145+ return Ok(back("trop", &form));
146+ }
147+
148+ let team = match teams::resolve(&state.pool, &wanted_team).await? {
149+ Resolved::One(t) => InitialTeam::Existing(t.id),
150+ Resolved::Ambiguous(_) => return Ok(back("equipe-ambigue", &form)),
151+ // Un identifiant qui ne désigne rien est une faute de frappe, pas une
152+ // intention de créer : seul un NOM libre ouvre une nouvelle équipe.
153+ Resolved::None { was_id: true } => return Ok(back("equipe-inconnue", &form)),
154+ Resolved::None { was_id: false } => InitialTeam::Create(wanted_team),
155+ };
156+
157+ let hash = password::hash(&form.password).map_err(AppError::Internal)?;
158+ let lang = crate::config::default_lang();
159+ match users::signup(&state.pool, &username, &hash, lang.code(), team).await? {
160+ Signup::UsernameTaken => Ok(back("pris", &form)),
161+ Signup::Created { user_id, team_id } => {
162+ state.signups.record(&ip);
163+ // Connexion immédiate : sans confirmation à attendre, renvoyer sur
164+ // le login ferait ressaisir ce qui vient d'être saisi.
165+ let jar = jar
166+ .add(session_cookie(&state, user_id)?)
167+ .add(team_cookie(team_id));
168+ Ok((jar, Redirect::to("/")).into_response())
169+ }
170+ }
171+}
172+
173+/// Retour au formulaire avec l'erreur et les champs non secrets rejoués.
174+fn back(err: &str, form: &SignupForm) -> Response {
175+ Redirect::to(&format!(
176+ "/inscription?err={err}&nom={}&equipe={}",
177+ crate::routes::urlenc(form.username.trim()),
178+ crate::routes::urlenc(form.equipe.trim())
179+ ))
180+ .into_response()
181+}
182+
183+#[cfg(test)]
184+mod tests {
185+ use super::valid_username;
186+
187+ #[test]
188+ fn username_rules() {
189+ for ok in ["camille", "jean-luc", "a", "élodie", "u".repeat(32).as_str()] {
190+ assert!(valid_username(ok), "« {ok} » devrait passer");
191+ }
192+ for ko in ["", "deux mots", "tab\there", "u".repeat(33).as_str()] {
193+ assert!(!valid_username(ko), "« {ko} » ne devrait pas passer");
194+ }
195+ }
196+
197+ /// La longueur se compte en CARACTÈRES : 32 accents ne doivent pas être
198+ /// refusés comme 64 octets.
199+ #[test]
200+ fn username_length_counts_chars_not_bytes() {
201+ assert!(valid_username(&"é".repeat(32)));
202+ assert!(!valid_username(&"é".repeat(33)));
203+ }
204+}
modified src/routes/teams.rs +10 -20
@@ -10,10 +10,6 @@ use crate::error::AppError;
1010 use crate::state::AppState;
1111 use crate::team::{ActiveTeam, clear_team_cookie, team_cookie};
1212
13-fn is_unique_violation(e: &sqlx::Error) -> bool {
14- matches!(e, sqlx::Error::Database(db) if db.is_unique_violation())
15-}
16-
1713 /// Encodage minimal pour rejouer un username dans une query string.
1814 #[derive(Deserialize)]
1915 pub struct NameForm {
@@ -35,17 +31,13 @@ pub async fn team_create(
3531 if name.is_empty() {
3632 return Ok(Redirect::to("/parametres?err=eq-vide").into_response());
3733 }
38- match teams::create_with_member(&state.pool, name, ctx.user.id).await {
39- Ok(id) => Ok((
40- jar.add(team_cookie(id)),
41- Redirect::to("/parametres?ok=equipe-creee"),
42- )
43- .into_response()),
44- Err(e) if is_unique_violation(&e) => {
45- Ok(Redirect::to("/parametres?err=eq-doublon").into_response())
46- }
47- Err(e) => Err(e.into()),
48- }
34+ // Les homonymes sont permis (migration 0012) : rien à refuser ici.
35+ let id = teams::create_with_member(&state.pool, name, ctx.user.id).await?;
36+ Ok((
37+ jar.add(team_cookie(id)),
38+ Redirect::to("/parametres?ok=equipe-creee"),
39+ )
40+ .into_response())
4941 }
5042
5143 #[derive(Deserialize)]
@@ -93,11 +85,9 @@ pub async fn team_rename(
9385 if name.is_empty() {
9486 return Ok(Redirect::to("/parametres?err=eq-vide"));
9587 }
96- match teams::rename(&state.pool, ctx.team_id, name, ctx.user.id).await {
97- Ok(0) => Err(AppError::NotFound),
98- Ok(_) => Ok(Redirect::to("/parametres?ok=eq-renommee")),
99- Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres?err=eq-doublon")),
100- Err(e) => Err(e.into()),
88+ match teams::rename(&state.pool, ctx.team_id, name, ctx.user.id).await? {
89+ 0 => Err(AppError::NotFound),
90+ _ => Ok(Redirect::to("/parametres?ok=eq-renommee")),
10191 }
10292 }
10393
@@ -10,10 +10,6 @@ use crate::error::AppError;
10 use crate::state::AppState;10 use crate::state::AppState;
11 use crate::team::{ActiveTeam, clear_team_cookie, team_cookie};11 use crate::team::{ActiveTeam, clear_team_cookie, team_cookie};
12 12
13-fn is_unique_violation(e: &sqlx::Error) -> bool {
14- matches!(e, sqlx::Error::Database(db) if db.is_unique_violation())
15-}
16-
17 /// Encodage minimal pour rejouer un username dans une query string.13 /// Encodage minimal pour rejouer un username dans une query string.
18 #[derive(Deserialize)]14 #[derive(Deserialize)]
19 pub struct NameForm {15 pub struct NameForm {
@@ -35,17 +31,13 @@ pub async fn team_create(
35 if name.is_empty() {31 if name.is_empty() {
36 return Ok(Redirect::to("/parametres?err=eq-vide").into_response());32 return Ok(Redirect::to("/parametres?err=eq-vide").into_response());
37 }33 }
38- match teams::create_with_member(&state.pool, name, ctx.user.id).await {34+ // Les homonymes sont permis (migration 0012) : rien à refuser ici.
39- Ok(id) => Ok((35+ let id = teams::create_with_member(&state.pool, name, ctx.user.id).await?;
40- jar.add(team_cookie(id)),36+ Ok((
41- Redirect::to("/parametres?ok=equipe-creee"),37+ jar.add(team_cookie(id)),
42- )38+ Redirect::to("/parametres?ok=equipe-creee"),
43- .into_response()),39+ )
44- Err(e) if is_unique_violation(&e) => {40+ .into_response())
45- Ok(Redirect::to("/parametres?err=eq-doublon").into_response())
46- }
47- Err(e) => Err(e.into()),
48- }
49 }41 }
50 42
51 #[derive(Deserialize)]43 #[derive(Deserialize)]
@@ -93,11 +85,9 @@ pub async fn team_rename(
93 if name.is_empty() {85 if name.is_empty() {
94 return Ok(Redirect::to("/parametres?err=eq-vide"));86 return Ok(Redirect::to("/parametres?err=eq-vide"));
95 }87 }
96- match teams::rename(&state.pool, ctx.team_id, name, ctx.user.id).await {88+ match teams::rename(&state.pool, ctx.team_id, name, ctx.user.id).await? {
97- Ok(0) => Err(AppError::NotFound),89+ 0 => Err(AppError::NotFound),
98- Ok(_) => Ok(Redirect::to("/parametres?ok=eq-renommee")),90+ _ => Ok(Redirect::to("/parametres?ok=eq-renommee")),
99- Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres?err=eq-doublon")),
100- Err(e) => Err(e.into()),
101 }91 }
102 }92 }
103 93
modified src/state.rs +7 -0
@@ -6,6 +6,7 @@ use sha2::{Digest, Sha256};
66 use sqlx::PgPool;
77
88 use crate::auth::revocation::RevocationCache;
9+use crate::ratelimit::Quota;
910
1011 #[derive(Clone)]
1112 pub struct AppState {
@@ -14,6 +15,10 @@ pub struct AppState {
1415 pub public: PublicKey,
1516 pub csrf_key: [u8; 32],
1617 pub revocations: RevocationCache,
18+ /// Comptes créés par IP (20/h) — en mémoire, jamais en base.
19+ pub signups: Quota,
20+ /// Clés d'inscription fausses par IP (10/jour) — frein à la force brute.
21+ pub bad_keys: Quota,
1722 }
1823
1924 impl AppState {
@@ -34,6 +39,8 @@ impl AppState {
3439 public,
3540 csrf_key,
3641 revocations: RevocationCache::default(),
42+ signups: Quota::signups(),
43+ bad_keys: Quota::bad_keys(),
3744 })
3845 }
3946 }
@@ -6,6 +6,7 @@ use sha2::{Digest, Sha256};
6 use sqlx::PgPool;6 use sqlx::PgPool;
7 7
8 use crate::auth::revocation::RevocationCache;8 use crate::auth::revocation::RevocationCache;
9+use crate::ratelimit::Quota;
9 10
10 #[derive(Clone)]11 #[derive(Clone)]
11 pub struct AppState {12 pub struct AppState {
@@ -14,6 +15,10 @@ pub struct AppState {
14 pub public: PublicKey,15 pub public: PublicKey,
15 pub csrf_key: [u8; 32],16 pub csrf_key: [u8; 32],
16 pub revocations: RevocationCache,17 pub revocations: RevocationCache,
18+ /// Comptes créés par IP (20/h) — en mémoire, jamais en base.
19+ pub signups: Quota,
20+ /// Clés d'inscription fausses par IP (10/jour) — frein à la force brute.
21+ pub bad_keys: Quota,
17 }22 }
18 23
19 impl AppState {24 impl AppState {
@@ -34,6 +39,8 @@ impl AppState {
34 public,39 public,
35 csrf_key,40 csrf_key,
36 revocations: RevocationCache::default(),41 revocations: RevocationCache::default(),
42+ signups: Quota::signups(),
43+ bad_keys: Quota::bad_keys(),
37 })44 })
38 }45 }
39 }46 }
modified templates/login.html +3 -0
@@ -34,5 +34,8 @@
3434 <button type="submit" class="btn btn-primary btn-block"
3535 style="min-height:56px;font-size:20px">{{ tr.sign_in }}</button>
3636 </form>
37+ {% if signup %}
38+ <a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/inscription">{{ tr.signup_link }}</a>
39+ {% endif %}
3740 </main>
3841 {% endblock %}
@@ -34,5 +34,8 @@
34 <button type="submit" class="btn btn-primary btn-block"34 <button type="submit" class="btn btn-primary btn-block"
35 style="min-height:56px;font-size:20px">{{ tr.sign_in }}</button>35 style="min-height:56px;font-size:20px">{{ tr.sign_in }}</button>
36 </form>36 </form>
37+ {% if signup %}
38+ <a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/inscription">{{ tr.signup_link }}</a>
39+ {% endif %}
37 </main>40 </main>
38 {% endblock %}41 {% endblock %}
added templates/signup.html +54 -0
new file mode 100644
@@ -0,0 +1,54 @@
1+{% extends "base.html" %}
2+{% block title %}{{ tr.signup_title }} · Ex Ego{% endblock %}
3+{% block lang %}{{ lang_code }}{% endblock %}
4+{% block body %}
5+<main class="login signup">
6+ {# Même logo « cadran ex aequo » que la connexion, en plus petit : la page
7+ est plus longue (trois champs + aides). #}
8+ <svg class="login-logo" width="96" height="96" viewBox="0 0 96 96" aria-hidden="true">
9+ <circle cx="48" cy="48" r="44" fill="#ebddc5"/>
10+ <circle cx="48" cy="48" r="40" fill="none" stroke="#201e1d" stroke-width="2.4" opacity=".38"/>
11+ <circle cx="48" cy="14" r="3.3" fill="#201e1d" opacity=".7"/>
12+ <circle cx="48" cy="82" r="3.3" fill="#201e1d" opacity=".5"/>
13+ <circle cx="14" cy="48" r="3.3" fill="#201e1d" opacity=".5"/>
14+ <circle cx="82" cy="48" r="3.3" fill="#201e1d" opacity=".5"/>
15+ <rect x="27" y="35.5" width="42" height="10.5" rx="5.25" fill="#c67139"/>
16+ <path d="M53.5 35.5 h10.25 a5.25 5.25 0 0 1 0 10.5 h-10.25 z" fill="#7a8a5e"/>
17+ <rect x="27" y="50" width="42" height="10.5" rx="5.25" fill="#7a8a5e"/>
18+ <path d="M43.5 50 h20.25 a5.25 5.25 0 0 1 0 10.5 h-20.25 z" fill="#c67139"/>
19+ </svg>
20+ <h1>{{ tr.signup_title }}</h1>
21+ <p class="tagline">{{ tr.signup_intro }}</p>
22+ <form method="post" action="/inscription">
23+ <div class="field" style="margin-bottom:var(--space-3)">
24+ <label for="username" style="font-size:18px">{{ tr.username_label }}</label>
25+ <input class="input" id="username" name="username" value="{{ username }}"
26+ autocomplete="username" autocapitalize="none" autocorrect="off"
27+ required autofocus maxlength="32" style="font-size:18px">
28+ </div>
29+ <div class="field" style="margin-bottom:var(--space-3)">
30+ <label for="password" style="font-size:18px">{{ tr.password_label }}</label>
31+ <input class="input" id="password" name="password" type="password"
32+ autocomplete="new-password" required minlength="8" style="font-size:18px">
33+ </div>
34+ <div class="field" style="margin-bottom:var(--space-2)">
35+ <label for="equipe" style="font-size:18px">{{ tr.signup_team_label }}</label>
36+ <input class="input" id="equipe" name="equipe" value="{{ team }}"
37+ autocapitalize="none" autocorrect="off" required
38+ aria-describedby="equipe-aide" style="font-size:18px">
39+ </div>
40+ <p class="field-hint" id="equipe-aide">{{ tr.signup_team_hint }}</p>
41+ <div class="field" style="margin-bottom:var(--space-2)">
42+ <label for="cle" style="font-size:18px">{{ tr.signup_key_label }}</label>
43+ <input class="input" id="cle" name="cle" type="password" autocomplete="off"
44+ autocapitalize="none" autocorrect="off" required
45+ aria-describedby="cle-aide" style="font-size:18px">
46+ </div>
47+ <p class="field-hint" id="cle-aide">{{ tr.signup_key_hint }}</p>
48+ {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %}
49+ <button type="submit" class="btn btn-primary btn-block"
50+ style="min-height:56px;font-size:20px">{{ tr.signup_submit }}</button>
51+ </form>
52+ <a class="btn btn-ghost" style="margin-top:var(--space-2)" href="/login">{{ tr.signup_have_account }}</a>
53+</main>
54+{% endblock %}
new file mode 100644
@@ -0,0 +1,54 @@
1+{% extends "base.html" %}
2+{% block title %}{{ tr.signup_title }} · Ex Ego{% endblock %}
3+{% block lang %}{{ lang_code }}{% endblock %}
4+{% block body %}
5+<main class="login signup">
6+ {# Même logo « cadran ex aequo » que la connexion, en plus petit : la page
7+ est plus longue (trois champs + aides). #}
8+ <svg class="login-logo" width="96" height="96" viewBox="0 0 96 96" aria-hidden="true">
9+ <circle cx="48" cy="48" r="44" fill="#ebddc5"/>
10+ <circle cx="48" cy="48" r="40" fill="none" stroke="#201e1d" stroke-width="2.4" opacity=".38"/>
11+ <circle cx="48" cy="14" r="3.3" fill="#201e1d" opacity=".7"/>
12+ <circle cx="48" cy="82" r="3.3" fill="#201e1d" opacity=".5"/>
13+ <circle cx="14" cy="48" r="3.3" fill="#201e1d" opacity=".5"/>
14+ <circle cx="82" cy="48" r="3.3" fill="#201e1d" opacity=".5"/>
15+ <rect x="27" y="35.5" width="42" height="10.5" rx="5.25" fill="#c67139"/>
16+ <path d="M53.5 35.5 h10.25 a5.25 5.25 0 0 1 0 10.5 h-10.25 z" fill="#7a8a5e"/>
17+ <rect x="27" y="50" width="42" height="10.5" rx="5.25" fill="#7a8a5e"/>
18+ <path d="M43.5 50 h20.25 a5.25 5.25 0 0 1 0 10.5 h-20.25 z" fill="#c67139"/>
19+ </svg>
20+ <h1>{{ tr.signup_title }}</h1>
21+ <p class="tagline">{{ tr.signup_intro }}</p>
22+ <form method="post" action="/inscription">
23+ <div class="field" style="margin-bottom:var(--space-3)">
24+ <label for="username" style="font-size:18px">{{ tr.username_label }}</label>
25+ <input class="input" id="username" name="username" value="{{ username }}"
26+ autocomplete="username" autocapitalize="none" autocorrect="off"
27+ required autofocus maxlength="32" style="font-size:18px">
28+ </div>
29+ <div class="field" style="margin-bottom:var(--space-3)">
30+ <label for="password" style="font-size:18px">{{ tr.password_label }}</label>
31+ <input class="input" id="password" name="password" type="password"
32+ autocomplete="new-password" required minlength="8" style="font-size:18px">
33+ </div>
34+ <div class="field" style="margin-bottom:var(--space-2)">
35+ <label for="equipe" style="font-size:18px">{{ tr.signup_team_label }}</label>
36+ <input class="input" id="equipe" name="equipe" value="{{ team }}"
37+ autocapitalize="none" autocorrect="off" required
38+ aria-describedby="equipe-aide" style="font-size:18px">
39+ </div>
40+ <p class="field-hint" id="equipe-aide">{{ tr.signup_team_hint }}</p>
41+ <div class="field" style="margin-bottom:var(--space-2)">
42+ <label for="cle" style="font-size:18px">{{ tr.signup_key_label }}</label>
43+ <input class="input" id="cle" name="cle" type="password" autocomplete="off"
44+ autocapitalize="none" autocorrect="off" required
45+ aria-describedby="cle-aide" style="font-size:18px">
46+ </div>
47+ <p class="field-hint" id="cle-aide">{{ tr.signup_key_hint }}</p>
48+ {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %}
49+ <button type="submit" class="btn btn-primary btn-block"
50+ style="min-height:56px;font-size:20px">{{ tr.signup_submit }}</button>
51+ </form>
52+ <a class="btn btn-ghost" style="margin-top:var(--space-2)" href="/login">{{ tr.signup_have_account }}</a>
53+</main>
54+{% endblock %}