import type { ResultSetHeader, RowDataPacket } from 'mysql2' import { db, DB } from './db' import { normalizeEmail, bnetMakeRegistration, gameMakeRegistration, makeGameAccountUsername } from './bnet' import { sendMail } from './mail' import { activationEmailHtml } from './emails' import { randomToken } from './random-token' const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/ 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 { 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( '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('SELECT id FROM account WHERE username = ?', [recruiter]) if (a[0]) { recruiterId = a[0].id } else { const [c] = await db(DB.characters).query( '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 { const [rows] = await db(DB.default).query( '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( '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( '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( '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 } }