← back to Trademarks Copyright
src/lib/rateLimit.ts
81 lines
import { createHash } from "node:crypto";
import { query } from "./db";
/**
* Fixed-window rate limit.
* Returns { allowed, remaining, resetAt } for a given (key, bucket) pair.
* Default: 3 operations per 60 minutes.
*/
export async function checkRateLimit(
key: string,
bucket: string,
opts: { max?: number; windowMinutes?: number } = {}
): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
const max = opts.max ?? 3;
const windowMin = opts.windowMinutes ?? 60;
const hash = createHash("sha256").update(key).digest("hex").slice(0, 32);
// Single-query upsert + read. The CTE ensures the count we RETURN is the
// post-increment count of the row we just touched — no TOCTOU window.
const { rows } = await query<{ count: number; window_start: string }>(
`INSERT INTO rate_limit (key_hash, bucket, count, window_start)
VALUES ($1, $2, 1, NOW())
ON CONFLICT (key_hash, bucket) DO UPDATE SET
count = CASE
WHEN rate_limit.window_start < NOW() - ($3 || ' minutes')::interval
THEN 1
ELSE rate_limit.count + 1
END,
window_start = CASE
WHEN rate_limit.window_start < NOW() - ($3 || ' minutes')::interval
THEN NOW()
ELSE rate_limit.window_start
END
RETURNING count, window_start`,
[hash, bucket, String(windowMin)]
);
const row = rows[0];
const resetAt = new Date(new Date(row.window_start).getTime() + windowMin * 60_000);
return {
allowed: row.count <= max,
remaining: Math.max(0, max - row.count),
resetAt,
};
}
/**
* Cron-callable cleanup — honors the 30-day retention we promise in the
* Privacy Policy for rate-limit rows.
*/
export async function purgeExpiredRateLimits(): Promise<number> {
const { rows } = await query<{ n: string }>(
`WITH deleted AS (
DELETE FROM rate_limit
WHERE window_start < NOW() - INTERVAL '30 days'
RETURNING 1
)
SELECT COUNT(*)::text AS n FROM deleted`
);
return Number(rows[0]?.n ?? 0);
}
/**
* Ring of known disposable / throwaway email domains.
* Non-exhaustive by design — we want to catch the obvious offenders, not all of them.
*/
const DISPOSABLE_DOMAINS = new Set([
"mailinator.com", "10minutemail.com", "10minutemail.net", "guerrillamail.com", "sharklasers.com",
"tempmail.com", "temp-mail.org", "temp-mail.io", "dispostable.com", "yopmail.com", "trashmail.com",
"throwawaymail.com", "getnada.com", "nada.ltd", "maildrop.cc", "mintemail.com",
"fakeinbox.com", "emailondeck.com", "spambog.com", "spamgourmet.com", "tempinbox.com",
"guerillamail.info", "inboxbear.com", "mt2015.com", "zeroe.ml", "mohmal.com",
]);
export function isDisposableEmail(email: string): boolean {
const at = email.lastIndexOf("@");
if (at < 0) return false;
const domain = email.slice(at + 1).toLowerCase().trim();
return DISPOSABLE_DOMAINS.has(domain);
}