import crypto from 'node:crypto' import type { RowDataPacket } from 'mysql2' import { db, DB } from './db' import { normalizeEmail, bnetMakeRegistration, EMAIL_RE } from './bnet' import { sendMail } from './mail' import { activationEmailHtml, accountNamesEmailHtml, passwordResetEmailHtml, securityTokensListEmailHtml, } from './emails' export interface Result { success: boolean error?: string redirect?: string } /** * Recuperación por email/usuario (anti-enumeración: respuesta genérica siempre). * `value` es el nombre de usuario (type=password) o el correo (resto). */ export async function requestRecovery(type: string, value: string): Promise { const site = process.env.SITE_URL || '' // Las CUATRO opciones se recuperan por CORREO (con Battle.net, la cuenta es el // correo). Solo se valida que lo sea: NO se exige Gmail, porque el registro solo // lo admite hoy pero las cuentas ya creadas tienen correos de todo tipo y no // podrían recuperarse. const email = (value || '').trim() if (!email || !EMAIL_RE.test(email)) return { success: false, error: 'invalidEmail' } if (type === 'password') { try { const [bn] = await db(DB.auth).query( 'SELECT email FROM battlenet_accounts WHERE email = ? LIMIT 1', [normalizeEmail(email)], ) // El correo se toma de la BD, NO el tecleado: el verifier SRP6 de bnet se // deriva del correo, así que `resetPassword` necesita exactamente la misma // forma (MAYÚSCULAS) o generaría un verifier con el que no se podría entrar. const found = bn[0]?.email if (found) { const token = crypto.randomBytes(32).toString('hex') await db(DB.default).query( 'INSERT INTO passwordreset (email, token, created_at, used) VALUES (?, ?, NOW(), 0)', [found, token], ) const link = `${site}/reset-password?token=${token}` await sendMail(found, 'Restablecer contraseña - NightSpire', passwordResetEmailHtml(found, link)) } } catch { /* acore no disponible: respuesta genérica igualmente */ } return { success: true } } if (type === 'securitytoken') { try { const [bn] = await db(DB.auth).query( 'SELECT id FROM battlenet_accounts WHERE email = ?', [normalizeEmail(email)], ) if (bn[0]) { const [games] = await db(DB.auth).query( 'SELECT id, username FROM account WHERE battlenet_account = ? ORDER BY battlenet_index', [bn[0].id], ) const ids = games.map((g) => g.id) if (ids.length) { const placeholders = ids.map(() => '?').join(',') const [toks] = await db(DB.default).query( `SELECT user_id, token FROM securitytoken WHERE user_id IN (${placeholders})`, ids, ) if (toks.length) { const nameById = new Map(games.map((g) => [g.id, g.username])) await sendMail( email, 'Tu Token de seguridad - NightSpire', securityTokensListEmailHtml( email, toks.map((r) => ({ username: String(nameById.get(r.user_id) ?? r.user_id), token: r.token })), ), ) } } } } catch { /* ignore */ } return { success: true } } if (type === 'accountname') { try { const [acc] = await db(DB.auth).query( 'SELECT id FROM battlenet_accounts WHERE email = ?', [normalizeEmail(email)], ) if (acc[0]) { const [games] = await db(DB.auth).query( 'SELECT username FROM account WHERE battlenet_account = ? ORDER BY battlenet_index', [acc[0].id], ) await sendMail( email, 'Tus cuentas de juego - NightSpire', accountNamesEmailHtml( email, games.map((g) => String(g.username)), ), ) } } catch { /* ignore */ } return { success: true } } if (type === 'activation') { try { const [rows] = await db(DB.default).query( 'SELECT hash, password FROM accountactivation WHERE email = ? AND old_email IS NULL ORDER BY created_at DESC LIMIT 1', [email], ) if (rows[0]) { const link = `${site}/activate-account?act=${rows[0].hash}` // Mismo correo que manda `registerAccount`: normalizado (MAYÚSCULAS) al // mostrarlo, sin normalizar como destinatario. await sendMail( email, `Activación de la cuenta ${normalizeEmail(email)} - NightSpire`, activationEmailHtml(normalizeEmail(email), link), ) } } catch { /* ignore */ } return { success: true } } return { success: false, error: 'invalidType' } } /** * ¿El enlace de restablecer sigue sirviendo? Mismas condiciones que `resetPassword` * (existe, sin usar y dentro de la hora) pero SIN consumirlo: lo llama la página al * abrirse, para no enseñar el formulario de un enlace que ya no vale. */ export async function isResetTokenValid(token: string): Promise { if (!token) return false try { const [rows] = await db(DB.default).query( 'SELECT created_at FROM passwordreset WHERE token = ? AND used = 0', [token], ) const pr = rows[0] if (!pr) return false return Date.now() - new Date(pr.created_at).getTime() <= 3600_000 } catch { return false } } export async function resetPassword(token: string, newPassword: string, confPassword: string): Promise { const [rows] = await db(DB.default).query( 'SELECT id, email, created_at FROM passwordreset WHERE token = ? AND used = 0', [token], ) const pr = rows[0] if (!pr) return { success: false, error: 'invalidLink' } if (Date.now() - new Date(pr.created_at).getTime() > 3600_000) return { success: false, error: 'expiredLink' } if (!newPassword || newPassword !== confPassword) return { success: false, error: 'passwordMismatch' } if (newPassword.length > 16) return { success: false, error: 'passwordTooLong' } try { const reg = bnetMakeRegistration(pr.email, newPassword) const [res] = await db(DB.auth).query( 'UPDATE battlenet_accounts SET srp_version = ?, salt = ?, verifier = ? WHERE email = ?', [reg.srpVersion, reg.salt, reg.verifier, normalizeEmail(pr.email)], ) // @ts-expect-error affectedRows if (!res.affectedRows) return { success: false, error: 'accountNotFound' } } catch { return { success: false, error: 'genericError' } } await db(DB.default).query('UPDATE passwordreset SET used = 1 WHERE id = ?', [pr.id]) return { success: true, redirect: '/log-in' } }