/** * Limitador de envios. * * Existe sobre todo para proteger a terceros. El formulario de acceso manda un * correo a la direccion que le escriban, sea de quien sea, asi que sin freno se * convierte en un boton para bombardear el buzon de cualquiera desde nuestro * dominio. El dano no lo sufrimos nosotros: lo sufre la persona bombardeada, y * despues nuestra reputacion de envio. * * Los contadores viven en la base de datos y no en memoria: un limite que se * borra al reiniciar el proceso no frena a quien insiste, y no vale nada si un * dia hay mas de una instancia. * * La decision es una funcion pura (`decidirLimite`) y el acceso a la base de * datos es una envoltura fina alrededor. Asi el comportamiento se prueba sin * montar una base de datos. */ import { eq, lt } from 'drizzle-orm'; import { db } from './db'; import { limite } from './db/schema'; export interface OpcionesLimite { /** Intentos permitidos dentro de la ventana. */ maximo: number; /** Duracion de la ventana en milisegundos. */ ventanaMs: number; } export interface ResultadoLimite { permitido: boolean; /** Intentos que quedan en la ventana actual. */ restantes: number; /** Segundos que faltan para poder reintentar. */ esperaSegundos: number; } /** Lo que hay guardado de una clave. */ export interface EstadoLimite { intentos: number; /** Instante en que expira la ventana, en milisegundos. */ expiraEn: number; } /** * Decide si se permite un intento y devuelve el estado que hay que guardar. * * La ventana no se renueva con cada intento: se fija en el primero, de modo * que insistir no alarga el bloqueo indefinidamente. */ export function decidirLimite( actual: EstadoLimite | null, opciones: OpcionesLimite, ahora: number ): { resultado: ResultadoLimite; siguiente: EstadoLimite } { const { maximo, ventanaMs } = opciones; if (!actual || actual.expiraEn <= ahora) { return { resultado: { permitido: true, restantes: maximo - 1, esperaSegundos: 0 }, siguiente: { intentos: 1, expiraEn: ahora + ventanaMs } }; } const intentos = actual.intentos + 1; const siguiente = { intentos, expiraEn: actual.expiraEn }; if (intentos > maximo) { return { resultado: { permitido: false, restantes: 0, esperaSegundos: Math.ceil((actual.expiraEn - ahora) / 1000) }, siguiente }; } return { resultado: { permitido: true, restantes: maximo - intentos, esperaSegundos: 0 }, siguiente }; } /** * Registra un intento para `clave` y dice si se permite. * * Va dentro de una transaccion porque leer y escribir por separado deja una * rendija: dos peticiones a la vez leerian el mismo contador y las dos * pasarian. */ export async function registrarIntento( clave: string, opciones: OpcionesLimite, ahora: number = Date.now() ): Promise { return db.transaction(async (tx) => { const [fila] = await tx.select().from(limite).where(eq(limite.clave, clave)).limit(1); const { resultado, siguiente } = decidirLimite( fila ? { intentos: fila.intentos, expiraEn: fila.expiraEn } : null, opciones, ahora ); await tx .insert(limite) .values({ clave, ...siguiente }) .onConflictDoUpdate({ target: limite.clave, set: siguiente }); return resultado; }); } /** * Una barrera del recorrido: una clave con su limite y el aviso que se da al * alcanzarlo. */ export interface Barrera { clave: string; maximo: number; ventanaMs: number; /** Aviso para quien lo alcanza. Recibe la espera en segundos. */ mensaje: (esperaSegundos: number) => string; /** * `true` cuando alcanzarla significa que algo va mal en el sitio y no que * alguien se haya pasado; se deja constancia en el registro del servidor. */ avisarEnConsola?: boolean; } export type ResultadoBarreras = { permitido: true } | { permitido: false; mensaje: string; esperaSegundos: number }; /** * Comprueba varias barreras en orden y se detiene en la primera que corta. * * El orden importa: al detenerse, las barreras siguientes no llegan a gastar * intento, de modo que una peticion rechazada por el enfriamiento no consume * tambien la cuota diaria. */ export async function comprobarBarreras( barreras: Barrera[], ahora: number = Date.now() ): Promise { for (const barrera of barreras) { const resultado = await registrarIntento( barrera.clave, { maximo: barrera.maximo, ventanaMs: barrera.ventanaMs }, ahora ); if (!resultado.permitido) { if (barrera.avisarEnConsola) { console.warn( `[limite] Barrera "${barrera.clave}" alcanzada; quedan ${resultado.esperaSegundos} s.` ); } return { permitido: false, mensaje: barrera.mensaje(resultado.esperaSegundos), esperaSegundos: resultado.esperaSegundos }; } } return { permitido: true }; } /** * Borra los contadores ya caducados. * * Se llama de vez en cuando desde los propios formularios en lugar de con una * tarea programada: la tabla crece despacio y asi no hay nada mas que * mantener. */ export async function limpiarCaducados(ahora: number = Date.now()): Promise { await db.delete(limite).where(lt(limite.expiraEn, ahora)); } /** Redondea una espera a minutos, para los avisos. */ export function enMinutos(esperaSegundos: number): number { return Math.max(1, Math.ceil(esperaSegundos / 60)); }