mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
v1.4.27 — Mobile capability and maintainer-finding cleanup. ResponsiveSheet primitive (bottom-sheet on narrow viewports, centred dialog on desktop) applied to BD, Weight, Glucose, Mood, Activity forms. Maintainer-finding cleanups across Coach, Insights, Reports, Privacy. GeoLite2 offline geocoding with resilience layer (graceful fallback when EULA not yet propagated). E2E gap-fill coverage. 450-line release notes attached to the GitHub Release.
65 lines
1.7 KiB
TypeScript
65 lines
1.7 KiB
TypeScript
/**
|
|
* PostgreSQL-based sliding window rate limiter.
|
|
* Uses atomic upsert via raw SQL for correctness across multiple instances.
|
|
*/
|
|
|
|
import { prisma } from "@/lib/db";
|
|
|
|
export interface RateLimitResult {
|
|
allowed: boolean;
|
|
remaining: number;
|
|
resetAt: number;
|
|
}
|
|
|
|
/**
|
|
* Check rate limit for a given key.
|
|
* Uses a single atomic SQL upsert — safe for concurrent requests.
|
|
* @param key Unique identifier (e.g., "auth:login:1.2.3.4")
|
|
* @param limit Max requests per window
|
|
* @param windowMs Window duration in milliseconds
|
|
*/
|
|
export async function checkRateLimit(
|
|
key: string,
|
|
limit: number,
|
|
windowMs: number,
|
|
): Promise<RateLimitResult> {
|
|
const windowInterval = `${windowMs} milliseconds`;
|
|
|
|
const rows = await prisma.$queryRaw<{ count: number; reset_at: Date }[]>`
|
|
INSERT INTO rate_limits (key, count, reset_at)
|
|
VALUES (${key}, 1, NOW() + ${windowInterval}::interval)
|
|
ON CONFLICT (key) DO UPDATE SET
|
|
count = CASE
|
|
WHEN rate_limits.reset_at < NOW() THEN 1
|
|
ELSE rate_limits.count + 1
|
|
END,
|
|
reset_at = CASE
|
|
WHEN rate_limits.reset_at < NOW() THEN NOW() + ${windowInterval}::interval
|
|
ELSE rate_limits.reset_at
|
|
END
|
|
RETURNING count, reset_at
|
|
`;
|
|
|
|
const row = rows[0];
|
|
const resetAt = row.reset_at.getTime();
|
|
const count = Number(row.count);
|
|
|
|
return {
|
|
allowed: count <= limit,
|
|
remaining: Math.max(0, limit - count),
|
|
resetAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Rate limit response headers.
|
|
*/
|
|
export function rateLimitHeaders(
|
|
result: RateLimitResult,
|
|
): Record<string, string> {
|
|
return {
|
|
"X-RateLimit-Remaining": String(result.remaining),
|
|
"X-RateLimit-Reset": new Date(result.resetAt).toISOString(),
|
|
};
|
|
}
|