Files
dsh_shenxian/web/register.html
T
admin 971ccc3703 feat(auth): 注册页人机验证 + 邮箱验证码;品牌标识去 DeepSeek(附域名迁移线 序㊿ 补提交)
三条线合并入库 —— 均已完成并上线(源码与生产一致,此前只部署未入仓)。
⚠️ 其中域名迁移线为**另一会话**产出,本会话只做入库、**未复验其正确性**(它自报零回归)。

【档案 134 · 注册页人机验证 + 邮箱验证码】
- DB 迁移 v10:users.email(唯一索引 LOWER(email))+ email_codes 事件表(2 索引)
- 新增模块 src/web/{register-guard,mail,turnstile,email-code}.ts
- routes/auth.ts:新增 GET /api/auth/register/config、POST /api/auth/register/email-code;
  注册接口加人机验证与验证码校验;config.ts 新增 12 项配置(默认空 ⇒ 不配 = 老行为)
- 邮件走**可插拔驱动**(brevo/http/log),发件人 [email protected](Brevo 域名已认证 + DKIM + SPF)
- 防爆破:三层配额(邮箱 6/h、8/天;IP 20/h;全局 200/h)+ 递增冷却阶梯
  (60→60→180→300→900→1800s)+ 试错 5 次作废 + 码只存哈希 + 单次使用 + 与用户名绑定
- Turnstile 服务端校 **success + action + hostname 三项**:sitekey 是公开的,
  只校 success 时"拿我们的 sitekey 在自己站点替真人取合法 token 再打我们接口"这条路是通的
- 新增 test/register-guard.test.mjs(19 用例)

【档案 137 · 品牌标识改造 — 去 DeepSeek 图形】
- login/register/admin 页头:删 DeepSeek 鲸鱼图标 + 「DeepSeek」文字图形
  → 平台标识(中文「能力枢纽」/英语及其他语言「CapabilityNet」,走 i18n 词条 brand.name)
- portal 顶栏换图标(页面名「管理门户」保留)
- 新建 web/favicon.svg(平台自有 hub 图标,避开 DeepSeek 蓝)+ 四页 favicon 指向它
- 新增 test/i18n-brand.test.mjs(node:vm 跑真实 i18n.js,六条语言路径断言渲染结果)
- scripts/verify-static.mjs 新增 SVG 段:XML 注释不得含 ASCII 双连字符(否则整份 SVG
  解析失败、图标静默不显示 —— 实际踩到过)
- 🔴 会话页面(实例内官方 dsh 界面)的标识**按用户要求未动**(也受 R2 约束)

【档案 135/136 · 域名迁移线(另一会话产出)】
- 域名收敛为 ai1net.com;旧域 alotbuy.com 降级为 301 过渡装置
- src/net/relay/{addr-override,directory,rendezvous,switcher}.ts 种子与候选链更新;
  src/web/server.ts、src/worker/relay-tunnel.ts、scripts/verify-cluster-domain.mjs
- 档案 136 = 控制面按两台中继取并集(**已立项、未落地**)

验证(本会话两条线):新增单测 21 条全通过|全量 221 pass / 0 fail / 1 skipped|
verify-static 全合格|其余 10 个 verify 脚本全 OK|线上实测:Turnstile 假 token 403、
发码 delivered、四页 deepseek 命中 0、favicon 200。
2026-09-19 09:11:24 +08:00

350 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title data-i18n="register.title">Sign up</title>
<link rel="icon" href="/favicon.svg" />
<link rel="stylesheet" href="/design.css" />
<link rel="stylesheet" href="/auth-ui.css" />
<!-- 档案 81 · R5:平台多语言运行时(默认英语) -->
<script src="/i18n.js"></script>
<style>
/* 档案 81 · R2-UI:提交忙碌态(点击后立刻可见;对齐 06-工作台UI规范 的按钮/圆角/主色) */
.btn[disabled] { opacity: .72; cursor: not-allowed; }
.btn.is-busy { display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
.btn .sp { width: 14px; height: 14px; flex: 0 0 14px; border: 2px solid rgba(255,255,255,.45);
border-top-color: #fff; border-radius: 50%; animation: btnsp .7s linear infinite; }
@keyframes btnsp { to { transform: rotate(360deg); } }
.msg { min-height: 20px; }
/* 档案 81 · R5:语言切换(页脚式下拉,不新增页面;中性样式,不引外链) */
.auth-lang { margin-top: 14px; display: flex; align-items: center; justify-content: center; gap: 8px; }
.auth-lang .i18n-lang-label { font-size: 13px; opacity: .72; }
.auth-lang .i18n-lang { font-size: 13px; padding: 3px 8px; border-radius: 6px; cursor: pointer;
border: 1px solid rgba(0,0,0,.16); background: transparent; color: inherit; }
@media (prefers-reduced-motion: reduce) { .btn .sp { animation: none; } }
/* 档案 134:邮箱验证码 + 人机验证(复用 .field 的版式,只加"输入框 + 按钮同一行"的排布) */
.code-row { display: flex; gap: 8px; align-items: stretch; }
.code-row input { flex: 1 1 auto; min-width: 0; }
.btn-code { flex: 0 0 auto; padding: 0 12px; min-height: 38px; border-radius: 8px; cursor: pointer;
border: 1px solid rgba(0,0,0,.18); background: transparent; color: inherit;
font-size: 13px; white-space: nowrap; }
.btn-code[disabled] { opacity: .6; cursor: not-allowed; }
.captcha-box { min-height: 65px; }
.captcha-box .cf-note { font-size: 12px; opacity: .66; }
</style>
</head>
<body class="auth-bg">
<div class="auth-card">
<div class="auth-brand">
<span class="wordmark" data-i18n="brand.name">CapabilityNet</span>
</div>
<p class="auth-sub" data-i18n="register.sub">Your account must be approved by an admin before you can sign in</p>
<form id="register">
<div class="field">
<label for="username" data-i18n="register.username">Username (3–32 letters / digits / underscore / hyphen)</label>
<input id="username" name="username" autocomplete="username" required />
</div>
<div class="field">
<label for="password" data-i18n="register.password">Password (at least 8 characters)</label>
<div class="pw-wrap">
<input id="password" name="password" type="password" autocomplete="new-password" required />
<button class="pw-toggle" type="button" id="pwToggle" data-i18n="common.show"
data-i18n-attr="aria-label:common.showPw" aria-label="Show password" aria-pressed="false">Show</button>
</div>
</div>
<!-- 档案 134:邮箱 + 验证码(仅在服务端启用邮箱验证时显示;配置读不到时也显示,由服务端定夺) -->
<div class="field" id="emailField">
<label for="email" data-i18n="register.email">E-mail</label>
<div class="code-row">
<input id="email" name="email" type="email" autocomplete="email"
placeholder="[email protected]" data-i18n-attr="placeholder:register.emailPh" />
<button class="btn-code" type="button" id="sendCode" data-i18n="register.sendCode">Send code</button>
</div>
</div>
<div class="field" id="codeField">
<label for="code" data-i18n="register.code">E-mail verification code</label>
<input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="8"
placeholder="6-digit code" data-i18n-attr="placeholder:register.codePh" />
</div>
<div class="field" id="captchaField" style="display:none">
<label data-i18n="register.captcha">Human verification</label>
<div class="captcha-box" id="captcha"><span class="cf-note" data-i18n="register.captchaLoading">Loading…</span></div>
</div>
<button class="btn primary" type="submit" data-i18n="register.submit">Sign up</button>
<p class="msg" id="msg"></p>
</form>
<p class="auth-alt"><span data-i18n="register.hasAccount">Already have an account?</span>
<a href="/login.html" data-i18n="register.signIn">Sign in</a></p>
<!-- 档案 81 · R5:语言切换(默认英语;切换写 cookie dsh_lang 后重载) -->
<div class="auth-lang"><div data-i18n-switch="label"></div></div>
</div>
<script>
// 档案 83 · 显示/隐藏密码(对齐 06-工作台UI规范 的 .btn-icon 交互;键盘可达 = 原生 button)
const pwInput = document.getElementById('password')
const pwBtn = document.getElementById('pwToggle')
if (pwInput && pwBtn) {
pwBtn.addEventListener('click', () => {
const show = pwInput.type === 'password'
pwInput.type = show ? 'text' : 'password'
pwBtn.textContent = I18N.t(show ? 'common.hide' : 'common.show')
pwBtn.setAttribute('aria-label', I18N.t(show ? 'common.hidePw' : 'common.showPw'))
pwBtn.setAttribute('aria-pressed', String(show))
pwInput.focus()
})
}
const form = document.getElementById('register')
const msg = document.getElementById('msg')
// 档案 81 · R2-UI:任何提交都要有"看得见"的忙碌态(否则几秒内用户会以为没反应)
function setBusy(busy, label) {
const btn = form.querySelector('button[type=submit]')
if (!btn) return
if (busy) {
if (!btn.dataset.idle) btn.dataset.idle = btn.innerHTML
btn.disabled = true
btn.classList.add('is-busy')
btn.innerHTML = '<span class="sp"></span><span>' + (label || I18N.t('common.busy')) + '</span>'
} else {
btn.disabled = false
btn.classList.remove('is-busy')
if (btn.dataset.idle) btn.innerHTML = btn.dataset.idle
}
}
function setMsg(text, kind) { msg.className = 'msg ' + (kind || 'err'); msg.textContent = text }
/* ── 档案 134 · 注册配置 / 邮箱验证码 / 人机验证 ──────────────────────────
三条设计取舍:
① **配置由服务端下发**(`/api/auth/register/config`):sitekey 随环境变,
写死在页面里会让"换环境忘换 key"表现为"控件在、但永远验不过"的静默失效。
② **配置读不到时仍显示邮箱字段**(不校验、照原样提交):服务端才是权威,
读不到配置就"整页降级成不可注册"是最差的失败方式。
③ **Turnstile token 单次有效** ⇒ 每次提交前取**新** token(用完即作废并 reset),
否则第二次请求必然拿到 `timeout-or-duplicate`。 */
const EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/
const USERNAME_RE = /^[a-zA-Z0-9_-]{3,32}$/
let cfg = { captcha: { enabled: false, siteKey: '', action: 'signup' }, emailCode: { enabled: false } }
let requireEmailCode = false
let captchaToken = ''
let widgetId = null
let resolveToken = null
let cooldownLeft = 0
let cooldownTimer = null
const emailField = document.getElementById('emailField')
const codeField = document.getElementById('codeField')
const captchaField = document.getElementById('captchaField')
const sendBtn = document.getElementById('sendCode')
function sendBtnLabel() {
sendBtn.textContent = cooldownLeft > 0
? I18N.t('register.resendIn', { n: cooldownLeft })
: I18N.t('register.sendCode')
sendBtn.disabled = cooldownLeft > 0
}
function startCooldown(seconds) {
cooldownLeft = Math.max(1, Math.floor(seconds || 60))
sendBtnLabel()
if (cooldownTimer) clearInterval(cooldownTimer)
cooldownTimer = setInterval(() => {
cooldownLeft -= 1
sendBtnLabel()
if (cooldownLeft <= 0) { clearInterval(cooldownTimer); cooldownTimer = null }
}, 1000)
}
/** 取一个**新鲜**的人机验证 token(无 token 时 reset 并等待回调;超时返回空串)。 */
function freshToken() {
if (!cfg.captcha.enabled) return Promise.resolve('')
if (captchaToken) return Promise.resolve(captchaToken)
return new Promise((resolve) => {
let settled = false
const finish = (value) => { if (!settled) { settled = true; resolveToken = null; resolve(value) } }
resolveToken = finish
try { if (widgetId !== null && window.turnstile) window.turnstile.reset(widgetId) } catch (e) { /* 忽略 */ }
setTimeout(() => finish(''), 12000)
})
}
function consumeToken() { captchaToken = '' }
function mapError(code, attemptsLeft) {
switch (code) {
case 'username_taken': return I18N.t('register.errTaken')
case 'email_taken': return I18N.t('register.errEmailTaken')
case 'invalid_email':
case 'email_required': return I18N.t('register.errEmail')
case 'invalid_username': return I18N.t('register.errUsername')
case 'code_attempts_exceeded': return I18N.t('register.errCodeExhausted')
case 'code_invalid':
case 'code_missing':
case 'code_expired':
case 'code_used':
case 'code_username_mismatch':
return attemptsLeft > 0
? I18N.t('register.errCode') + ' (' + attemptsLeft + ')'
: I18N.t('register.errCode')
case 'mail_send_failed': return I18N.t('register.errMail')
case 'captcha_failed':
case 'captcha_unavailable': return I18N.t('register.errCaptcha')
case 'email_code_disabled': return I18N.t('register.errUnavailable')
default: return I18N.t('register.errFailed')
}
}
function loadScript(src) {
return new Promise((resolve, reject) => {
const s = document.createElement('script')
s.src = src
s.async = true
s.defer = true
s.onload = () => resolve()
s.onerror = () => reject(new Error('load-failed'))
document.head.appendChild(s)
})
}
async function boot() {
let loaded = null
try {
const res = await fetch('/api/auth/register/config', { headers: { accept: 'application/json' } })
if (res.ok) loaded = await res.json()
} catch (e) { /* 见取舍 ②:读不到就降级,不放倒整页 */ }
if (loaded) {
cfg = {
captcha: loaded.captcha || { enabled: false, siteKey: '', action: 'signup' },
emailCode: loaded.emailCode || { enabled: false },
}
requireEmailCode = !!cfg.emailCode.enabled
}
// 字段显隐:启用时才必填;配置读不到时"显示但不强制"(服务端会给出明确错误)。
const showEmail = loaded ? requireEmailCode : true
emailField.style.display = showEmail ? '' : 'none'
codeField.style.display = showEmail ? '' : 'none'
sendBtnLabel()
if (!cfg.captcha.enabled || !cfg.captcha.siteKey) return
captchaField.style.display = ''
const box = document.getElementById('captcha')
try {
await loadScript('https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit')
if (!window.turnstile) throw new Error('no-api')
box.innerHTML = ''
widgetId = window.turnstile.render(box, {
sitekey: cfg.captcha.siteKey,
// action 由服务端下发(与服务端 siteverify 校的是同一份取值)——
// 它是「这枚 token 为哪个业务签发」的标签,⛔ 不要在前端另写一个常量。
action: cfg.captcha.action || 'signup',
callback: (token) => { captchaToken = token; if (resolveToken) resolveToken(token) },
'expired-callback': () => { captchaToken = '' },
'error-callback': () => { captchaToken = '' },
})
} catch (e) {
// 加载失败 ⇒ **明确告知 + 不给提交**(提交也一定被服务端拒,不如先说清楚)
box.innerHTML = ''
setMsg(I18N.t('register.errCaptchaLoad'))
form.querySelector('button[type=submit]').disabled = true
}
}
sendBtn.addEventListener('click', async () => {
if (cooldownLeft > 0) return
setMsg('')
const username = form.username.value.trim()
const email = form.email.value.trim()
if (!USERNAME_RE.test(username)) return setMsg(I18N.t('register.errUsername'))
if (!EMAIL_RE.test(email)) return setMsg(I18N.t('register.errEmail'))
const token = await freshToken()
if (cfg.captcha.enabled && token === '') return setMsg(I18N.t('register.errCaptchaToken'))
sendBtn.disabled = true
sendBtn.textContent = I18N.t('register.sendingCode')
try {
const res = await fetch('/api/auth/register/email-code', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, email, captchaToken: token }),
})
const body = await res.json().catch(() => ({}))
consumeToken() // 无论成败,token 都已用过
if (!res.ok) {
const wait = Number(body.retryAfterSeconds || 0)
if (res.status === 429) {
if (wait > 0) startCooldown(wait)
setMsg(I18N.t('register.errCooldown', { n: wait > 0 ? wait : 60 }))
} else {
setMsg(mapError(body.error, body.attemptsLeft))
}
} else {
startCooldown(body.retryAfterSeconds || 60)
setMsg(I18N.t('register.codeSent'), 'ok')
}
} catch (e) {
consumeToken()
setMsg(I18N.t('register.errMail'))
} finally {
if (cooldownLeft <= 0) { sendBtn.disabled = false; sendBtn.textContent = I18N.t('register.sendCode') }
}
})
form.addEventListener('submit', async (event) => {
event.preventDefault()
setMsg('')
const username = form.username.value.trim()
const password = form.password.value
if (!USERNAME_RE.test(username)) return setMsg(I18N.t('register.errUsername'))
if (password.length < 8) return setMsg(I18N.t('register.errPassword'))
let email = ''
let code = ''
if (emailField.style.display !== 'none') {
email = form.email.value.trim()
code = form.code.value.trim()
const touched = email !== '' || code !== ''
if (requireEmailCode || touched) {
if (!EMAIL_RE.test(email)) return setMsg(I18N.t('register.errEmail'))
if (!/^\d{4,8}$/.test(code)) return setMsg(I18N.t('register.errEmailRequired'))
}
}
let token = ''
if (cfg.captcha.enabled) {
token = await freshToken()
if (token === '') return setMsg(I18N.t('register.errCaptchaToken'))
}
setBusy(true, I18N.t('register.signingUp'))
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password, email, code, captchaToken: token }),
})
const body = await res.json().catch(() => ({}))
if (cfg.captcha.enabled) consumeToken()
if (!res.ok) {
setBusy(false)
return setMsg(mapError(body.error, body.attemptsLeft))
}
msg.className = 'msg ok'
msg.textContent = I18N.t('register.ok')
setBusy(false)
form.querySelector('button[type=submit]').disabled = true
form.reset()
} catch (e) {
if (cfg.captcha.enabled) consumeToken()
setBusy(false)
setMsg(I18N.t('register.errFailed'))
}
})
boot()
</script>
</body>
</html>