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.
194 lines
5.6 KiB
194 lines
5.6 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 { lt, sql } 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> {
|
|
const nuevaExpiracion = ahora + opciones.ventanaMs;
|
|
const filas = await db.execute<{ intentos: number; expiraEn: number }>(sql`
|
|
insert into limite (clave, intentos, expira_en)
|
|
values (${clave}, 1, ${nuevaExpiracion})
|
|
on conflict (clave) do update set
|
|
intentos = case
|
|
when limite.expira_en <= ${ahora} then 1
|
|
else limite.intentos + 1
|
|
end,
|
|
expira_en = case
|
|
when limite.expira_en <= ${ahora} then ${nuevaExpiracion}
|
|
else limite.expira_en
|
|
end
|
|
returning intentos, expira_en as "expiraEn"
|
|
`);
|
|
|
|
const estado = filas.rows[0];
|
|
if (!estado) throw new Error(`No se pudo registrar el límite «${clave}».`);
|
|
|
|
const permitido = estado.intentos <= opciones.maximo;
|
|
return {
|
|
permitido,
|
|
restantes: permitido ? opciones.maximo - estado.intentos : 0,
|
|
esperaSegundos: permitido ? 0 : Math.max(0, Math.ceil((estado.expiraEn - ahora) / 1000))
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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));
|
|
}
|