ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
161 lines (142 loc) • 4.34 kB
text/typescript
/**
* 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';
}