diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-01 11:06:47 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-01 11:06:47 +0200 |
| commit | b6c15f35d570d4f54901b811654991847502ca82 (patch) | |
| tree | a3d0a42c3b48aa7ac618094cedc59b5b3f329376 /packages/meshbay-hub/src/meshbay_hub/static | |
| parent | 1c8eb6577e36e1e4a150afd0cdda8d283172385e (diff) | |
| download | meshbay-b6c15f35d570d4f54901b811654991847502ca82.tar.gz | |
feat(hub): reCAPTCHA v2 on Register and Password Reset pages
Server-side verification module, CaptchaConfig in hub.toml,
captcha_site_key exposed via /v1/hub/info, useCaptcha() hook
in the SPA with stable DOM rendering (strength bar always present
to avoid Preact re-ordering the captcha widget). Native clients
(auth_key path) skip captcha. All 10 locales updated.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
11 files changed, 115 insertions, 16 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js index 7c92d52..df08bc4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js @@ -1,5 +1,5 @@ import { - html, useState, + html, useState, useEffect, useRef, useCallback, } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { @@ -10,6 +10,75 @@ import * as platform from './platform.js'; const PASSWORD_MIN_BITS = 60; const PASSWORD_MIN_LEN = 12; +// ── reCAPTCHA v2 helper ────────────────────────────────────────────────────── + +let _captchaSiteKey = null; +let _captchaKeyFetched = false; + +async function fetchCaptchaSiteKey() { + if (_captchaKeyFetched) return _captchaSiteKey; + try { + const info = await hubFetch('/v1/hub/info'); + _captchaSiteKey = info.captcha_site_key || null; + } catch { _captchaSiteKey = null; } + _captchaKeyFetched = true; + return _captchaSiteKey; +} + +function loadRecaptchaScript() { + if (document.getElementById('recaptcha-script')) return; + const s = document.createElement('script'); + s.id = 'recaptcha-script'; + s.src = 'https://www.google.com/recaptcha/api.js?render=explicit'; + s.async = true; + s.defer = true; + document.head.appendChild(s); +} + +function useCaptcha() { + const [siteKey, setSiteKey] = useState(_captchaSiteKey); + const [token, setToken] = useState(null); + const containerRef = useRef(null); + const widgetId = useRef(null); + + useEffect(() => { + fetchCaptchaSiteKey().then(k => { + if (k) { setSiteKey(k); loadRecaptchaScript(); } + }); + }, []); + + useEffect(() => { + if (!siteKey || !containerRef.current) return; + const poll = setInterval(() => { + if (window.grecaptcha && window.grecaptcha.render && widgetId.current === null) { + clearInterval(poll); + widgetId.current = window.grecaptcha.render(containerRef.current, { + sitekey: siteKey, + callback: (tk) => setToken(tk), + 'expired-callback': () => setToken(null), + theme: document.documentElement.getAttribute('data-theme') === 'dark' + ? 'dark' : 'light', + }); + } + }, 100); + return () => clearInterval(poll); + }, [siteKey]); + + const reset = useCallback(() => { + if (widgetId.current !== null && window.grecaptcha) { + window.grecaptcha.reset(widgetId.current); + setToken(null); + } + }, []); + + const widget = siteKey + ? html`<div ref=${containerRef} + style="display:flex;justify-content:center;margin:12px 0"></div>` + : null; + + return { token, widget, reset, enabled: !!siteKey }; +} + function passwordBits(pw) { if (!pw) return 0; let pool = 0; @@ -142,6 +211,7 @@ export function RegisterPage() { const [recoverySaved, setRecoverySaved] = useState(false); const [recoveryCopied, setRecoveryCopied] = useState(false); const [emailRecovery, setEmailRecovery] = useState(true); + const captcha = useCaptcha(); const onSubmit = async (e) => { e.preventDefault(); @@ -177,7 +247,11 @@ export function RegisterPage() { } else { await hubFetch('/v1/users/register', { method: 'POST', - body: { username: name, email, password, pk_user_ed25519: '', pk_user_x25519: '' }, + body: { + username: name, email, password, + pk_user_ed25519: '', pk_user_x25519: '', + captcha_token: captcha.token, + }, }); setPhase('verify'); } @@ -322,18 +396,16 @@ export function RegisterPage() { <input type="password" placeholder="${t('register.password')}" value=${password} onInput=${e => setPassword(e.target.value)} autocomplete="new-password" required minlength="8" /> - ${password && html` - <div style="margin:-4px 0 10px"> - <div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden"> - <div style=${`height:100%;width:${Math.min(100, passwordBits(password) / 100 * 100)}%; - background:${passwordBits(password) < PASSWORD_MIN_BITS ? 'var(--error)' - : passwordBits(password) < 80 ? 'var(--yellow, #f59e0b)' : 'var(--success)'}`}></div> - </div> - <p style="font-size:0.8em;color:var(--text-dim);margin-top:4px"> - ${t('register.strength', { bits: passwordBits(password) })} - </p> + <div style=${`margin:-4px 0 10px;${password ? '' : 'visibility:hidden;height:0;margin:0;overflow:hidden'}`}> + <div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden"> + <div style=${`height:100%;width:${Math.min(100, passwordBits(password) / 100 * 100)}%; + background:${passwordBits(password) < PASSWORD_MIN_BITS ? 'var(--error)' + : passwordBits(password) < 80 ? 'var(--yellow, #f59e0b)' : 'var(--success)'}`}></div> </div> - `} + <p style="font-size:0.8em;color:var(--text-dim);margin-top:4px"> + ${t('register.strength', { bits: passwordBits(password) })} + </p> + </div> <input type="password" placeholder="${t('register.confirm')}" value=${confirm} onInput=${e => setConfirm(e.target.value)} autocomplete="new-password" required /> @@ -343,8 +415,9 @@ export function RegisterPage() { onChange=${e => setEmailRecovery(e.target.checked)} /> <span>${t('register.recovery_email_opt')}</span> </label> + ${captcha.widget} ${error && html`<div class="error-msg">${error}</div>`} - <button type="submit" disabled=${loading}> + <button type="submit" disabled=${loading || (captcha.enabled && !captcha.token)}> ${loading ? t('register.loading') : t('register.submit')} </button> </form> @@ -375,6 +448,7 @@ export function ResetPasswordPage({ onLogin }) { const [busy, setBusy] = useState(false); const [progress, setProgress] = useState(null); const [result, setResult] = useState(null); + const captcha = useCaptcha(); const requestCode = async (e) => { e.preventDefault(); @@ -384,7 +458,10 @@ export function ResetPasswordPage({ onLogin }) { try { await hubFetch('/v1/users/password/reset-request', { method: 'POST', - body: { username: username.trim(), email: email.trim() }, + body: { + username: username.trim(), email: email.trim(), + captcha_token: captcha.token, + }, }); setPhase('form'); } catch (err) { @@ -493,8 +570,10 @@ export function ResetPasswordPage({ onLogin }) { <input type="email" placeholder="${t('register.email')}" value=${email} onInput=${e => setEmail(e.target.value)} autocomplete="email" required /> + ${captcha.widget} ${error && html`<div class="error-msg">${error}</div>`} - <button type="submit" disabled=${busy}>${t('reset.send_code')}</button> + <button type="submit" disabled=${busy || (captcha.enabled && !captcha.token)}> + ${t('reset.send_code')}</button> </form>`} ${phase === 'form' && html` diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 363df86..fcd138d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -92,6 +92,8 @@ export default { 'register.strength': 'Stärke: etwa {bits} Bit. Sie schützt die Kopie Ihrer ' + 'Schlüssel, die auf den Nodes liegt, denen Sie beitreten — es lohnt sich also, ' + 'sie gut zu wählen.', + 'captcha.required': 'Bitte das Captcha ausfüllen.', + 'captcha.failed': 'Captcha-Überprüfung fehlgeschlagen — bitte erneut versuchen.', // Home 'home.welcome': 'Willkommen bei MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 14a42a9..a265527 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -93,6 +93,8 @@ export default { + 'your keys where they are stored — a few unrelated words work well.', 'register.strength': 'Strength: about {bits} bits. This protects the copy of ' + 'your keys kept on the nodes you join, so it is worth getting right.', + 'captcha.required': 'Please complete the captcha.', + 'captcha.failed': 'Captcha verification failed — please try again.', // Home 'home.welcome': 'Welcome to MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index ab1829a..1505ffe 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -90,6 +90,8 @@ export default { + 'relación entre sí funcionan muy bien.', 'register.strength': 'Solidez: unos {bits} bits. Protege la copia de sus claves que ' + 'se guarda en los nodes a los que se une, así que conviene acertar.', + 'captcha.required': 'Por favor, complete el captcha.', + 'captcha.failed': 'Verificación del captcha fallida — inténtelo de nuevo.', // Home 'home.welcome': 'Bienvenido a MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index bfa707c..95729e5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -91,6 +91,8 @@ export default { 'register.strength': 'Robustesse : environ {bits} bits. Elle protège la copie de ' + 'vos clés conservée sur les nodes que vous rejoignez, cela vaut donc la peine ' + 'de la soigner.', + 'captcha.required': 'Veuillez compléter le captcha.', + 'captcha.failed': 'Vérification du captcha échouée — veuillez réessayer.', // Home 'home.welcome': 'Bienvenue sur MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 0f92692..faf0592 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -91,6 +91,8 @@ export default { + 'legame tra loro funziona bene.', 'register.strength': 'Robustezza: circa {bits} bit. Protegge la copia delle sue ' + 'chiavi conservata sui node a cui aderisce, quindi vale la pena sceglierla con cura.', + 'captcha.required': 'Completa il captcha.', + 'captcha.failed': 'Verifica captcha fallita — riprova.', // Home 'home.welcome': 'Benvenuto in MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 1e5a9f5..3a354de 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -89,6 +89,8 @@ export default { + 'その鍵を守るものです。互いに関係のない単語をいくつか並べると効果的です。', 'register.strength': '強度:約 {bits} ビット。参加している各 node に保管される鍵の' + 'コピーを守るものですので、しっかり決めておく価値があります。', + 'captcha.required': 'キャプチャを完了してください。', + 'captcha.failed': 'キャプチャの検証に失敗しました — もう一度お試しください。', // Home 'home.welcome': 'MeshBay へようこそ', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 3cd34c3..d66fafd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -92,6 +92,8 @@ export default { 'register.strength': 'Sterkte: ongeveer {bits} bits. Ze beschermt de kopie van uw ' + 'sleutels die op de nodes staat waar u zich bij aansluit, dus het loont om er ' + 'even bij stil te staan.', + 'captcha.required': 'Vul de captcha in.', + 'captcha.failed': 'Captchaverificatie mislukt — probeer het opnieuw.', // Home 'home.welcome': 'Welkom bij MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 10fe306..74a4dac 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -96,6 +96,8 @@ export default { + 'bardzo dobrze.', 'register.strength': 'Siła: około {bits} bitów. Chroni kopię kluczy przechowywaną na ' + 'nodes, do których się dołącza, więc warto ją dobrać starannie.', + 'captcha.required': 'Proszę wypełnić captcha.', + 'captcha.failed': 'Weryfikacja captcha nie powiodła się — spróbuj ponownie.', // Home 'home.welcome': 'Witamy w MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 9d69b85..e478133 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -92,6 +92,8 @@ export default { + 'entre si funcionam muito bem.', 'register.strength': 'Robustez: cerca de {bits} bits. Ela protege a cópia das suas ' + 'chaves mantida nos nodes dos quais você participa, então vale a pena caprichar.', + 'captcha.required': 'Por favor, complete o captcha.', + 'captcha.failed': 'Verificação do captcha falhou — tente novamente.', // Home 'home.welcome': 'Bem-vindo ao MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index fc141c2..1d537b5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -89,6 +89,8 @@ export default { + '几个互不相关的词就很好用。', 'register.strength': '强度:约 {bits} 位。它保护的是您所加入的各个 node 上保存的密钥副本,' + '因此值得认真设置。', + 'captcha.required': '请完成验证码。', + 'captcha.failed': '验证码验证失败——请重试。', // Home 'home.welcome': '欢迎使用 MeshBay', |