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