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
+103
View File
@@ -0,0 +1,103 @@
import type { RowDataPacket, ResultSetHeader } from 'mysql2'
import { db, DB } from './db'
import { checkSecurityToken } from './security-token'
import { executeSoapCommand } from './soap'
/**
* Renombrar hermandad: el usuario elige una hermandad de la que sea Maestro
* (guild_member.rank = 0) y le pone un nombre nuevo. Cuesta 1000 PD y requiere
* el token de seguridad. El renombrado se aplica por SOAP (.guild rename).
*/
export const GUILD_RENAME_PD = 1000
/** Nombre de hermandad: A-Za-z y espacio, máx. 24 (regla del juego / diseño). */
const NAME_RE = /^[A-Za-z ]{1,24}$/
export interface GmGuild {
guildid: number
name: string
}
/** Hermandades de las que la cuenta tiene un personaje Maestro (rank 0). */
export async function getGuildMasterGuilds(accountId: number): Promise<GmGuild[]> {
if (!accountId) return []
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
`SELECT g.guildid, g.name
FROM guild g
JOIN guild_member gm ON g.guildid = gm.guildid
JOIN characters c ON c.guid = gm.guid
WHERE c.account = ? AND gm.rank = 0
ORDER BY g.name`,
[accountId],
)
return rows.map((r) => ({ guildid: Number(r.guildid), name: r.name }))
} catch {
return []
}
}
export interface Result {
success: boolean
error?: string
cost?: number
newName?: string
}
export async function renameGuild(params: {
accountId: number
guildId: number
newName: string
token: string
}): Promise<Result> {
const { accountId, guildId, token } = params
const newName = (params.newName || '').trim()
if (!accountId || !guildId || !newName || !token) return { success: false, error: 'missingFields' }
if (!NAME_RE.test(newName)) return { success: false, error: 'invalidName' }
if (!(await checkSecurityToken(accountId, token))) return { success: false, error: 'invalidToken' }
// La hermandad debe ser una de las que la cuenta dirige (Maestro).
const guilds = await getGuildMasterGuilds(accountId)
const guild = guilds.find((g) => g.guildid === guildId)
if (!guild) return { success: false, error: 'notGuildMaster' }
const oldName = guild.name
if (/["\r\n]/.test(oldName)) return { success: false, error: 'genericError' } // nombre antiguo no seguro para SOAP
// El nuevo nombre no puede coincidir con otra hermandad existente.
try {
const [dup] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT guildid FROM guild WHERE name = ? AND guildid <> ? LIMIT 1',
[newName, guildId],
)
if (dup[0]) return { success: false, error: 'nameTaken' }
} catch {
return { success: false, error: 'genericError' }
}
// Cobra 1000 PD de forma atómica (condicional sobre el saldo).
let charged = false
try {
const [res] = await db(DB.default).query<ResultSetHeader>(
'UPDATE home_api_points SET dp = dp - ? WHERE accountID = ? AND dp >= ?',
[GUILD_RENAME_PD, accountId, GUILD_RENAME_PD],
)
charged = res.affectedRows === 1
} catch {
return { success: false, error: 'genericError' }
}
if (!charged) return { success: false, error: 'insufficientPoints' }
// Aplica el renombrado en el servidor por SOAP (convención del proyecto:
// respuesta no nula = aplicado, igual que soapFulfill en paid-services). Si el
// worldserver está caído, devuelve los PD cobrados.
const soap = await executeSoapCommand(`.guild rename "${oldName}" "${newName}"`)
if (soap === null) {
await db(DB.default)
.query('UPDATE home_api_points SET dp = dp + ? WHERE accountID = ?', [GUILD_RENAME_PD, accountId])
.catch(() => {})
return { success: false, error: 'soapError' }
}
return { success: true, cost: GUILD_RENAME_PD, newName }
}
+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[]>(
+23
View File
@@ -31,6 +31,29 @@ export async function getVoteSites(): Promise<VoteSite[]> {
}
}
export interface VoteSiteStatus extends VoteSite {
lastVoteAt: string | null // ISO del último voto de la cuenta en este sitio, o null
}
/** Sitios de votación con la fecha del último voto de la cuenta (para el recuadro). */
export async function getVoteSitesForAccount(accountId: number): Promise<VoteSiteStatus[]> {
const sites = await getVoteSites()
if (!accountId) return sites.map((s) => ({ ...s, lastVoteAt: null }))
try {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
'SELECT vote_site_id, MAX(created_at) AS last FROM home_votelog WHERE account_id = ? GROUP BY vote_site_id',
[accountId],
)
const map = new Map(rows.map((r) => [Number(r.vote_site_id), r.last as Date]))
return sites.map((s) => {
const last = map.get(s.id)
return { ...s, lastVoteAt: last ? new Date(last).toISOString() : null }
})
} catch {
return sites.map((s) => ({ ...s, lastVoteAt: null }))
}
}
export async function registerVote(accountId: number, url: string): Promise<VoteResult> {
const [siteRows] = await db(DB.default).query<RowDataPacket[]>(
'SELECT id, name, points FROM home_votesite WHERE url = ?',