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.
senzapaura_es/src/lib/server/limite.ts

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));
}

Powered by TurnKey Linux.