c255724ebf
Dos fallos que se juntaban justo en «recuperar nombre de cuenta»: - La validación de Gmail era sensible a mayúsculas (`/@gmail\.com$/` sin la `i`), así que NOVAWOW86@GMAIL.COM se rechazaba con invalidEmail. Y desde que los correos muestran la cuenta en MAYÚSCULAS, quien la copiara de ahí y la pegara no podía recuperar la cuenta, ni registrarse, ni cambiar el correo: la misma regex estaba repetida en register/change-email/recover. Ahora es una sola, con la `i`, en lib/bnet.ts (junto a normalizeEmail). El dominio de un correo NO distingue mayúsculas. - Los correos enseñaban el username interno («17#1»), que no le dice nada a nadie, en vez del nombre visible («WOW1»). Afectaba a DOS plantillas: la de nombres de cuenta y la de la lista de tokens. La conversión existía, pero como helper local de my-account; sube a lib/bnet.ts como `gameAccountDisplayName` (el inverso de `makeGameAccountUsername`) y la aplican las propias plantillas, para que ningún sitio que las use pueda olvidarse. Verificado: la regex acepta las 3 variantes de caja y sigue rechazando no-Gmail; los dos correos renderizados muestran «Cuenta: WOW1 / WOW2». Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
141 lines
5.9 KiB
TypeScript
141 lines
5.9 KiB
TypeScript
import type { ResultSetHeader, RowDataPacket } from 'mysql2'
|
|
import { db, DB } from './db'
|
|
import { normalizeEmail, bnetMakeRegistration, gameMakeRegistration, makeGameAccountUsername, GMAIL_RE } from './bnet'
|
|
import { sendMail } from './mail'
|
|
import { activationEmailHtml } from './emails'
|
|
import { randomToken } from './random-token'
|
|
|
|
|
|
export interface RegisterInput {
|
|
password: string
|
|
confPassword: string
|
|
email: string
|
|
confEmail: string
|
|
recruiter?: string
|
|
}
|
|
|
|
export interface Result {
|
|
success: boolean
|
|
error?: string
|
|
/** Solo al activar: el correo de la cuenta, para poder decir cuál se activó. */
|
|
email?: string
|
|
}
|
|
|
|
export async function registerAccount(input: RegisterInput): Promise<Result> {
|
|
const password = (input.password || '').trim()
|
|
const confPassword = (input.confPassword || '').trim()
|
|
const email = (input.email || '').trim()
|
|
const confEmail = (input.confEmail || '').trim()
|
|
const recruiter = (input.recruiter || '').trim()
|
|
|
|
if (!password || !email) return { success: false, error: 'missingFields' }
|
|
if (password !== confPassword) return { success: false, error: 'passwordMismatch' }
|
|
if (password.length > 16) return { success: false, error: 'passwordTooLong' }
|
|
if (email !== confEmail || !GMAIL_RE.test(email)) return { success: false, error: 'invalidEmail' }
|
|
|
|
// ¿ya existe una cuenta Battle.net con ese email?
|
|
try {
|
|
const [rows] = await db(DB.auth).query<RowDataPacket[]>(
|
|
'SELECT COUNT(*) AS n FROM battlenet_accounts WHERE email = ?',
|
|
[normalizeEmail(email)],
|
|
)
|
|
if (Number(rows[0]?.n ?? 0) > 0) return { success: false, error: 'emailExists' }
|
|
} catch {
|
|
// BD de cuentas no disponible en dev: continuamos.
|
|
}
|
|
|
|
// Borrar cualquier activación de registro pendiente para ese email
|
|
await db(DB.default).query('DELETE FROM accountactivation WHERE email = ? AND old_email IS NULL', [email])
|
|
|
|
// Reclutador opcional
|
|
let recruiterId = 0
|
|
if (recruiter) {
|
|
try {
|
|
const [a] = await db(DB.auth).query<RowDataPacket[]>('SELECT id FROM account WHERE username = ?', [recruiter])
|
|
if (a[0]) {
|
|
recruiterId = a[0].id
|
|
} else {
|
|
const [c] = await db(DB.characters).query<RowDataPacket[]>(
|
|
'SELECT account FROM characters WHERE name = ?',
|
|
[recruiter],
|
|
)
|
|
if (c[0]) recruiterId = c[0].account
|
|
else return { success: false, error: 'recruiterNotFound' }
|
|
}
|
|
} catch {
|
|
return { success: false, error: 'recruiterNotFound' }
|
|
}
|
|
}
|
|
|
|
// 32 alfanuméricos, como el `get_random_string(32)` original (col hash = varchar(32)).
|
|
// Se portó como `randomBytes(16).toString('hex')`, que llenaba los 32 caracteres pero
|
|
// solo con `0-9a-f`: cambiaba el formato del enlace de activación sin querer.
|
|
const hash = randomToken(32)
|
|
await db(DB.default).query(
|
|
'INSERT INTO accountactivation (email, password, recruiter_id, hash, created_at, is_used, is_new_email_used) VALUES (?, ?, ?, ?, NOW(), 0, 0)',
|
|
[email, password, recruiterId, hash],
|
|
)
|
|
|
|
const link = `${process.env.SITE_URL}/activate-account?act=${hash}`
|
|
// El correo se muestra normalizado (MAYÚSCULAS) en el asunto y en el cuerpo, igual
|
|
// que en el del token de seguridad, que lo saca de `session.bnetEmail` (ya normalizado).
|
|
// El DESTINATARIO va sin normalizar: es la dirección tal cual la escribió el usuario.
|
|
await sendMail(
|
|
email,
|
|
`Activación de la cuenta ${normalizeEmail(email)} - NightSpire`,
|
|
activationEmailHtml(normalizeEmail(email), link),
|
|
)
|
|
|
|
return { success: true }
|
|
}
|
|
|
|
export async function activateAccount(hash: string, lastIp = '0.0.0.0'): Promise<Result> {
|
|
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
|
'SELECT id, email, password, recruiter_id, created_at FROM accountactivation WHERE hash = ? AND old_email IS NULL',
|
|
[hash],
|
|
)
|
|
const act = rows[0]
|
|
if (!act) return { success: false, error: 'invalidLink' }
|
|
if (Date.now() - new Date(act.created_at).getTime() > 3600_000) return { success: false, error: 'expiredLink' }
|
|
|
|
const emailNorm = normalizeEmail(act.email)
|
|
const password: string = act.password
|
|
|
|
// Volver a comprobar que el correo sigue libre. `registerAccount` ya lo mira, pero
|
|
// entre registrarse y activar puede haberse creado la cuenta (otra activación
|
|
// pendiente del mismo correo, un cambio de correo confirmado...), y sin esto se
|
|
// insertaba una bnet duplicada: `battlenet_accounts.email` no tiene índice único.
|
|
const [dup] = await db(DB.auth).query<RowDataPacket[]>(
|
|
'SELECT COUNT(*) AS n FROM battlenet_accounts WHERE email = ?',
|
|
[emailNorm],
|
|
)
|
|
if (Number(dup[0]?.n ?? 0) > 0) {
|
|
// La activación ya no sirve para nada: la cuenta existe. Se borra para que el
|
|
// enlace no quede dando vueltas.
|
|
await db(DB.default).query('DELETE FROM accountactivation WHERE id = ?', [act.id])
|
|
return { success: false, error: 'emailExists' }
|
|
}
|
|
|
|
// Cuenta Battle.net (SRP6 v2)
|
|
const bnet = bnetMakeRegistration(emailNorm, password)
|
|
const [bnetRes] = await db(DB.auth).query<ResultSetHeader>(
|
|
'INSERT INTO battlenet_accounts (email, srp_version, salt, verifier) VALUES (?, ?, ?, ?)',
|
|
[emailNorm, bnet.srpVersion, bnet.salt, bnet.verifier],
|
|
)
|
|
const bnetId = bnetRes.insertId
|
|
|
|
// Cuenta de juego (SRP6 Grunt)
|
|
const gameUsername = makeGameAccountUsername(bnetId, 1)
|
|
const game = gameMakeRegistration(gameUsername, password)
|
|
await db(DB.auth).query<ResultSetHeader>(
|
|
'INSERT INTO account (username, salt, verifier, email, reg_mail, recruiter, joindate, last_ip, expansion, battlenet_account, battlenet_index) ' +
|
|
'VALUES (?, ?, ?, ?, ?, ?, NOW(), ?, ?, ?, 1)',
|
|
[gameUsername, game.salt, game.verifier, emailNorm, emailNorm, act.recruiter_id || 0, lastIp, 2, bnetId],
|
|
)
|
|
|
|
await db(DB.default).query('DELETE FROM accountactivation WHERE id = ?', [act.id])
|
|
// El correo tal cual se registró, no `emailNorm`: ese va en MAYÚSCULAS porque lo
|
|
// exige el SRP6 de Battle.net, y en pantalla quedaría como INNA@INNA.CL.
|
|
return { success: true, email: act.email }
|
|
}
|