Files
NightSpire/web-next/lib/security-token.ts
T
Inna 9390eefa93 Correos: portar el diseño del tema y unificarlos en lib/emails.ts
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>
2026-07-15 14:33:32 +00:00

56 lines
2.2 KiB
TypeScript

import crypto from 'node:crypto'
import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db'
import { sendMail } from './mail'
import { securityTokenEmailHtml } from './emails'
import type { SessionData } from './session'
export interface Result {
success: boolean
error?: string
tokenDate?: string
}
/** Solicita un token de seguridad (6 caracteres) por email. 1 cada 7 días. */
export async function requestSecurityToken(session: SessionData, ip: string): Promise<Result> {
const userId = session.accountId ?? 0
const email = session.bnetEmail ?? ''
if (!email) return { success: false, error: 'noEmail' }
const [existing] = await db(DB.default).query<RowDataPacket[]>(
'SELECT id, created_at FROM home_securitytoken WHERE user_id = ? ORDER BY created_at DESC LIMIT 1',
[userId],
)
if (existing[0]) {
const days = (Date.now() - new Date(existing[0].created_at).getTime()) / 86400_000
if (days < 7) return { success: false, error: 'cooldown' }
}
const token = crypto.randomBytes(4).toString('base64url').slice(0, 6)
const expiresAt = new Date(Date.now() + 7 * 86400_000)
await db(DB.default).query('DELETE FROM home_securitytoken WHERE user_id = ?', [userId])
await db(DB.default).query(
'INSERT INTO home_securitytoken (token, created_at, expires_at, ip_address, user_id) VALUES (?, NOW(), ?, ?, ?)',
[token, expiresAt, ip || '0.0.0.0', userId],
)
// La cuenta es el correo: con Battle.net se entra con él, no con un usuario.
await sendMail(email, 'Token de seguridad - Nova WoW', securityTokenEmailHtml(email, token, ip || '0.0.0.0'))
const [created] = await db(DB.default).query<RowDataPacket[]>(
'SELECT created_at FROM home_securitytoken WHERE user_id = ? ORDER BY created_at DESC LIMIT 1',
[userId],
)
return { success: true, tokenDate: created[0] ? new Date(created[0].created_at).toISOString() : undefined }
}
/** Comprueba que el token coincide con el de la cuenta. */
export async function checkSecurityToken(userId: number, token: string): Promise<boolean> {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
'SELECT token FROM home_securitytoken WHERE user_id = ? ORDER BY created_at DESC LIMIT 1',
[userId],
)
return Boolean(rows[0] && rows[0].token === token)
}