UNPKG

ethercalc

Version:

Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)

161 lines (142 loc) 4.34 kB
/** * Optional per-source request rate limiting for self-host deployments. * * Gated by `ETHERCALC_RATELIMIT` (default off). When enabled, applies a * token bucket keyed on `CF-Connecting-IP` / the first `X-Forwarded-For` * hop. Hosted Cloudflare deploys leave the flag unset and rely on the * platform edge instead (AGENTS.md §13 Q7). */ export interface RateLimitEnv { readonly ETHERCALC_RATELIMIT?: string | null; } export interface RateLimitConfig { /** Token bucket capacity (burst). */ readonly capacity: number; /** Tokens added per elapsed second. */ readonly refillPerSec: number; } export interface RateLimitResult { readonly allowed: boolean; /** Whole seconds until a request would likely succeed (429 Retry-After). */ readonly retryAfterSec?: number; } interface BucketState { tokens: number; lastRefillMs: number; } /** Aligns with the nginx recipe's `rate=10r/s` + `burst=30`. */ const DEFAULT_CAPACITY = 30; const DEFAULT_REFILL_PER_SEC = 10; function rateLimitDisabled(raw: string): boolean { switch (raw.trim().toLowerCase()) { case '': case '0': case 'false': case 'no': case 'off': return true; default: return false; } } /** * Parse `ETHERCALC_RATELIMIT`. * * - unset / false-like → `null` (disabled) * - `1` / `true` / `yes` / `on` → nginx-aligned default (10 r/s, burst 30) * - plain number `N` → `N` requests per second, burst `max(3N, N)` * - `window:max` → `max` requests per `window` seconds (e.g. `60:600`) * - unparseable → `null` (fail closed to off, not deny-all) */ export function parseRateLimitConfig( raw: string | null | undefined, ): RateLimitConfig | null { if (raw == null) return null; const trimmed = raw.trim(); if (rateLimitDisabled(trimmed)) return null; switch (trimmed.toLowerCase()) { case '1': case 'true': case 'yes': case 'on': return { capacity: DEFAULT_CAPACITY, refillPerSec: DEFAULT_REFILL_PER_SEC, }; } const colon = trimmed.indexOf(':'); if (colon >= 0) { const windowSec = Number(trimmed.slice(0, colon).trim()); const maxRequests = Number(trimmed.slice(colon + 1).trim()); if ( !Number.isFinite(windowSec) || !Number.isFinite(maxRequests) || windowSec <= 0 || maxRequests <= 0 ) { return null; } return { capacity: maxRequests, refillPerSec: maxRequests / windowSec, }; } const rps = Number(trimmed); if (!Number.isFinite(rps) || rps <= 0) return null; return { capacity: Math.max(rps * 3, rps), refillPerSec: rps, }; } export function rateLimitConfigFromEnv(env: RateLimitEnv): RateLimitConfig | null { return parseRateLimitConfig(env.ETHERCALC_RATELIMIT); } export function clientIpFromHeaders(headers: Headers): string { const cf = headers.get('CF-Connecting-IP')?.trim(); if (cf) return cf; const first = headers.get('X-Forwarded-For')?.split(',')[0]?.trim(); if (first) return first; return 'unknown'; } export interface RateLimitStore { consume(ip: string, config: RateLimitConfig, nowMs?: number): RateLimitResult; /** Test hook — drop all per-IP state. */ reset(): void; } export function createRateLimitStore(): RateLimitStore { const buckets = new Map<string, BucketState>(); return { consume(ip, config, nowMs = Date.now()) { let bucket = buckets.get(ip); if (!bucket) { bucket = { tokens: config.capacity, lastRefillMs: nowMs }; buckets.set(ip, bucket); } const elapsedSec = Math.max(0, (nowMs - bucket.lastRefillMs) / 1000); if (elapsedSec > 0) { bucket.tokens = Math.min( config.capacity, bucket.tokens + elapsedSec * config.refillPerSec, ); bucket.lastRefillMs = nowMs; } if (bucket.tokens >= 1) { bucket.tokens -= 1; return { allowed: true }; } const deficit = 1 - bucket.tokens; const retryAfterSec = Math.max( 1, Math.ceil(deficit / config.refillPerSec), ); return { allowed: false, retryAfterSec }; }, reset() { buckets.clear(); }, }; } /** Paths that bypass the optional limiter (health probes). */ export function isRateLimitExemptPath(pathname: string): boolean { return pathname === '/_health'; }