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

This commit is contained in:
2026-10-26 01:25:59 +00:00
parent 0ee981a96b
commit 5e812271e1
58 changed files with 47689 additions and 9 deletions

View 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';
}
}