9390eefa93
Los correos usaban un shell inline distinto en cada remitente. Ahora salen todos de una sola maqueta, la de security_token, portada de las plantillas de Django. De las 7 de Django solo security_token y activation traían el diseño; las otras 5 eran HTML soso. Las plantillas de Django eran un volcado del DOM de Gmail. Al portarlas se quitó lo que no era diseño: los enlaces iban envueltos en el rastreador de Mailjet (079xk.mjt.lu), que además redirigía a ultimowow.com en vez de a nosotros; había un píxel de apertura, un "Click on me" oculto y atributos que mete Gmail al mostrar el correo. Las imágenes apuntaban al proxy de Gmail (ci3.googleusercontent.com), no a nuestro /static/, así que el diseño se rompía el día que Google dejara de servirlas. Ninguna URL va a fuego: todas cuelgan de SITE_URL, así que cambiar de dominio es tocar el .env. En un correo han de ser absolutas (se abre desde Gmail), así que se construyen a partir de esa variable. El logo nw-mail-logo.png daba 404: solo existía el .webp del tema, que es el de UltimoWoW (león negro + letras "UW"). El auténtico de NovaWoW, un león de acero sin letras, sobrevivía únicamente en la caché de Gmail y se recuperó de ahí (600x320 RGBA, íntegro). Verificado renderizando el original y el nuestro en un navegador y comparando: cuadran pixel a pixel. Eso destapó dos fallos que se corrigen aquí: faltaba la etiqueta <h1> de apertura, y el estilo de los párrafos de datos era el de la letra pequeña del pie (11px/17px) en vez del real (14px/21px). El activation de Django mandaba la contraseña en texto plano; aquí no se manda. mail.ts: leía EMAIL_USE_SSL pero ignoraba EMAIL_USE_TLS, así que en el puerto 587 el STARTTLS era oportunista y, si el servidor no lo ofrecía, nodemailer enviaba en claro. Ahora requireTLS lo hace obligatorio. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
180 lines
6.0 KiB
TypeScript
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 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',
|
|
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 home_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 - Nova WoW',
|
|
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 - Nova WoW',
|
|
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 home_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} - Nova WoW`,
|
|
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 home_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 home_passwordreset SET used = 1 WHERE id = ?', [pr.id])
|
|
return { success: true, redirect: '/log-in' }
|
|
}
|