worldwide todo list: public todo list of 68 real, sourced problems (planet to street) at /projects/worldwide-todo. worldtodo schema (categories, tasks, change_requests), idempotent seed on deploy, public add/edit/delete requests with Turnstile + salted daily-rotating IP-hash rate limit, owner-only review queue (approve, edit & approve, reject) with live updates over /ws/worldtodo(-owner), JSON API, detail pages with OSM map and history; listed in projects, header, /start and sitemap
Some checks failed
Deploy production / deploy (push) Failing after 9s
Some checks failed
Deploy production / deploy (push) Failing after 9s
This commit is contained in:
66
src/lib/server/worldtodo/limit.ts
Normal file
66
src/lib/server/worldtodo/limit.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { createHmac, randomBytes } from 'node:crypto';
|
||||
|
||||
// Rate limiting for public change requests: 5 per hour and 20 per day per client.
|
||||
// The client key is an HMAC of the IP address with a random salt that lives only in this
|
||||
// process's memory and rotates every UTC day, so a key can't be linked to an IP (or to
|
||||
// yesterday's key) and nothing is ever written to Postgres or logs. Counters are per process
|
||||
// and reset on restart, like the site's other limiters.
|
||||
|
||||
export const PER_HOUR = 5;
|
||||
export const PER_DAY = 20;
|
||||
const HOUR = 3_600_000;
|
||||
const DAY = 86_400_000;
|
||||
const MAX_KEYS = 50_000;
|
||||
|
||||
type State = { day: string; salt: Buffer; hits: Map<string, number[]> };
|
||||
|
||||
export function createLimiter(now: () => number = Date.now) {
|
||||
let state: State | null = null;
|
||||
const current = () => {
|
||||
const day = new Date(now()).toISOString().slice(0, 10);
|
||||
if (!state || state.day !== day) state = { day, salt: randomBytes(32), hits: new Map() };
|
||||
return state;
|
||||
};
|
||||
const keyOf = (ip: string) =>
|
||||
createHmac('sha256', current().salt).update(ip).digest('base64url').slice(0, 22);
|
||||
|
||||
return {
|
||||
/** The salted hash for an IP (exposed for tests; never stored). */
|
||||
keyOf,
|
||||
/**
|
||||
* Count one attempt. Returns null when allowed, or the seconds to wait (Retry-After).
|
||||
* Blocked attempts are not counted.
|
||||
*/
|
||||
hit(ip: string): number | null {
|
||||
const s = current();
|
||||
const key = keyOf(ip);
|
||||
const t = now();
|
||||
const list = (s.hits.get(key) ?? []).filter((x) => t - x < DAY);
|
||||
const lastHour = list.filter((x) => t - x < HOUR);
|
||||
if (lastHour.length >= PER_HOUR)
|
||||
return Math.max(1, Math.ceil((lastHour[0] + HOUR - t) / 1000));
|
||||
if (list.length >= PER_DAY) return Math.max(1, Math.ceil((list[0] + DAY - t) / 1000));
|
||||
list.push(t);
|
||||
if (!s.hits.has(key) && s.hits.size >= MAX_KEYS) s.hits.delete(s.hits.keys().next().value!);
|
||||
s.hits.set(key, list);
|
||||
return null;
|
||||
},
|
||||
/** Number of tracked keys (tests). */
|
||||
size: () => current().hits.size
|
||||
};
|
||||
}
|
||||
|
||||
export const limiter = createLimiter();
|
||||
|
||||
/** The client IP behind Cloudflare/Caddy, same order as server.js and the listening stream. */
|
||||
export function clientIp(request: Request, getClientAddress: () => string): string {
|
||||
const h = request.headers;
|
||||
const forwarded =
|
||||
h.get('cf-connecting-ip') ?? h.get('x-forwarded-for')?.split(',')[0]?.trim() ?? '';
|
||||
if (forwarded) return forwarded;
|
||||
try {
|
||||
return getClientAddress();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user