You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
185 lines
5.2 KiB
185 lines
5.2 KiB
/**
|
|
* 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<ResultadoLimite> {
|
|
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<ResultadoBarreras> {
|
|
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<void> {
|
|
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));
|
|
}
|