web-next: rediseño de security-token, recover, vote-points + rename-guild

- /security-token: diseño completo del original (info + advertencia + NOTA 7 días),
  fecha en formato HH:MM:SS DD-MM-YYYY, botón "Token enviado", enlace a /recover.
- /recover: 4 opciones (contraseña por usuario, nombre de cuenta, token de seguridad
  y enlace de activación por correo); recuperación de token nueva; Turnstile por envío.
- /vote-points: caja de info con las 7 secciones Q&A + panel "Sitios de votación" con
  tarjetas inline-div (imagen, PV, último voto), botón refrescar; abre el sitio y acredita
  PV al volver. lib/vote.ts + getVoteSitesForAccount.
- /rename-guild: renombra una hermandad de la que la cuenta es Maestro por 1000 PD +
  token de seguridad; SOAP .guild rename con comillas; reembolsa los PD si SOAP falla.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:06:49 +00:00
parent 66d056c04b
commit b9803adeb9
15 changed files with 778 additions and 116 deletions
+81 -23
View File
@@ -19,35 +19,48 @@ function emailShell(title: string, bodyHtml: string): string {
</div></body></html>`
}
/** Recuperación por email (anti-enumeración: respuesta genérica siempre). */
export async function requestRecovery(type: string, email: string): Promise<Result> {
email = (email || '').trim()
if (!email || !GMAIL_RE.test(email)) return { success: false, error: 'invalidEmail' }
/**
* 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 [rows] = await db(DB.auth).query<RowDataPacket[]>(
'SELECT id FROM battlenet_accounts WHERE email = ?',
[normalizeEmail(email)],
const [acc] = await db(DB.auth).query<RowDataPacket[]>(
'SELECT battlenet_account FROM account WHERE username = ? LIMIT 1',
[value],
)
if (rows[0]) {
const token = crypto.randomBytes(32).toString('hex')
await db(DB.default).query(
'INSERT INTO home_passwordreset (email, token, created_at, used) VALUES (?, ?, NOW(), 0)',
[email, token],
)
const link = `${site}/reset-password?token=${token}`
await sendMail(
email,
'Restablecer contraseña - Nova WoW',
emailShell(
'Restablecer contraseña',
`<p>Pulsa para elegir una nueva contraseña (válido 1 hora):</p>
<p><a href="${link}" style="display:inline-block;padding:12px 22px;background:#d79602;color:#1b120b;text-decoration:none;border-radius:6px;font-weight:bold">Restablecer</a></p>
<p style="font-size:13px;word-break:break-all"><a href="${link}" style="color:#2471a9">${link}</a></p>`,
),
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 home_passwordreset (email, token, created_at, used) VALUES (?, ?, NOW(), 0)',
[email, token],
)
const link = `${site}/reset-password?token=${token}`
await sendMail(
email,
'Restablecer contraseña - Nova WoW',
emailShell(
'Restablecer contraseña',
`<p>Pulsa para elegir una nueva contraseña (válido 1 hora):</p>
<p><a href="${link}" style="display:inline-block;padding:12px 22px;background:#d79602;color:#1b120b;text-decoration:none;border-radius:6px;font-weight:bold">Restablecer</a></p>
<p style="font-size:13px;word-break:break-all"><a href="${link}" style="color:#2471a9">${link}</a></p>`,
),
)
}
}
} catch {
/* acore no disponible: respuesta genérica igualmente */
@@ -55,6 +68,51 @@ export async function requestRecovery(type: string, email: string): Promise<Resu
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 home_securitytoken WHERE user_id IN (${placeholders})`,
ids,
)
if (toks.length) {
const nameById = new Map(games.map((g) => [g.id, g.username]))
const list = toks
.map((r) => `<li><strong>${nameById.get(r.user_id) ?? r.user_id}</strong>: <code>${r.token}</code></li>`)
.join('')
await sendMail(
email,
'Tu Token de seguridad - Nova WoW',
emailShell(
'Tu Token de seguridad',
`<p>Token(s) de seguridad de las cuentas asociadas a <strong>${email}</strong>:</p><ul>${list}</ul>
<p style="font-size:13px;color:#b1997f">Consérvalo en un lugar seguro y no lo compartas.</p>`,
),
)
}
}
}
} catch {
/* ignore */
}
return { success: true }
}
if (type === 'accountname') {
try {
const [acc] = await db(DB.auth).query<RowDataPacket[]>(