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:
@@ -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
@@ -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[]>(
|
||||
|
||||
@@ -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 = ?',
|
||||
|
||||
Reference in New Issue
Block a user