Files
NightSpire/web-next/lib/recover.ts
T
Inna 579396c34a Rebranding a NightSpire en toda la web
Marca, dominios y correos: UltimoWoW/Nova WoW -> NightSpire, cualquier URL de
esos dominios -> https://www.nightspire.gg y los correos -> consultas@nightspire.gg.
98 sustituciones en 16 ficheros de textos de usuario (messages/, app/, components/).

Assets: logo largo de cabecera y vídeo sustituidos por los de NightSpire (el logo
nuevo es 214x28, igual que el viejo, así que no hace falta tocar el CSS).

Redes: apuntan a las cuentas de NightSpire, tanto en el pie de la web como en el
de los correos (que seguían enlazando a las de NovaWoW desde el porte del diseño).

Cookie de sesión novawow_session -> nightspire_session. Cierra la sesión de todos
los conectados una vez; se asume ahora, recién hecho el cutover.

Ficheros del tema renombrados: novawow-style.css -> nightspire-style.css y
novawow-main-logo-transparent.webp -> nightspire-main-logo-transparent.webp, para
que la marca vieja no quede ni en las rutas.

El foro externo (foro.ultimowow.com) pasa a ser el nuestro: <Link> a /forum, que
respeta el idioma y no abre pestaña nueva.

NO se tocan y es a propósito:
- Los comentarios que nombran AzerothCore: describen el comportamiento REAL del
  core (límite de 12 ítems por correo, convención de baneos, SOAP...). Cambiarlos
  a NightSpire los volvería falsos. Además AzerothCore no aparece en la web: las
  14 menciones son todas comentarios de código.
- El regex de brandify() sigue buscando los nombres VIEJOS: son los que hay
  escritos en las noticias de la BD.
- lib/changelog.ts REPO='Inna/NovaWoW': es la ruta real del repo en Gitea.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 15:15:57 +00:00

180 lines
6.0 KiB
TypeScript

import crypto from 'node:crypto'
import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db'
import { normalizeEmail, bnetMakeRegistration } from './bnet'
import { sendMail } from './mail'
import {
activationEmailHtml,
accountNamesEmailHtml,
passwordResetEmailHtml,
securityTokensListEmailHtml,
} from './emails'
const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/
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<Result> {
value = (value || '').trim()
const site = process.env.SITE_URL || ''
// La contraseña se recupera por NOMBRE DE USUARIO (p.ej. 15#1): se resuelve el
// correo Battle.net asociado y se envía el enlace de restablecimiento.
if (type === 'password') {
if (!value || value.length > 20) return { success: false, error: 'invalidUsername' }
try {
const [acc] = await db(DB.auth).query<RowDataPacket[]>(
'SELECT battlenet_account FROM account WHERE username = ? LIMIT 1',
[value],
)
const bnetId = acc[0]?.battlenet_account
if (bnetId) {
const [bn] = await db(DB.auth).query<RowDataPacket[]>(
'SELECT email FROM battlenet_accounts WHERE id = ?',
[bnetId],
)
const email = bn[0]?.email
if (email) {
const token = crypto.randomBytes(32).toString('hex')
await db(DB.default).query(
'INSERT INTO passwordreset (email, token, created_at, used) VALUES (?, ?, NOW(), 0)',
[email, token],
)
const link = `${site}/reset-password?token=${token}`
await sendMail(
email,
'Restablecer contraseña - NightSpire',
passwordResetEmailHtml(email, link),
)
}
}
} catch {
/* acore no disponible: respuesta genérica igualmente */
}
return { success: true }
}
// El resto se recupera por CORREO.
const email = value
if (!email || !GMAIL_RE.test(email)) return { success: false, error: 'invalidEmail' }
if (type === 'securitytoken') {
try {
const [bn] = await db(DB.auth).query<RowDataPacket[]>(
'SELECT id FROM battlenet_accounts WHERE email = ?',
[normalizeEmail(email)],
)
if (bn[0]) {
const [games] = await db(DB.auth).query<RowDataPacket[]>(
'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<RowDataPacket[]>(
`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<RowDataPacket[]>(
'SELECT id FROM battlenet_accounts WHERE email = ?',
[normalizeEmail(email)],
)
if (acc[0]) {
const [games] = await db(DB.auth).query<RowDataPacket[]>(
'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<RowDataPacket[]>(
'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}`
await sendMail(
email,
`Activación de la cuenta ${email} - NightSpire`,
activationEmailHtml(email, link),
)
}
} catch {
/* ignore */
}
return { success: true }
}
return { success: false, error: 'invalidType' }
}
export async function resetPassword(token: string, newPassword: string, confPassword: string): Promise<Result> {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
'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' }
}