Files
Marco df05ea5358 Add rate limiting, sliding sessions, email verification, GDPR self-service, order cancellation/returns, and critical-error alerting
Complements Payload's per-account login lockout with per-IP rate limiting
on auth routes; proxy.ts silently refreshes an active customer's session
via Payload's built-in refresh-token endpoint instead of a long-lived
token. Registration now sends a non-blocking email-verification link
(doesn't gate login, since checkout registers and immediately logs in
mid-purchase). /konto/profil gets GDPR export/delete; order detail pages
get self-service cancel/return-request, backed by a Payload hook that
closes a real gap (a customer's JWT could previously PATCH any field of
their own order, not just status). Checkout failures now email an alert
independent of Payload's own health, since Kuma's uptime checks can't see
an order silently failing to persist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 07:28:01 +00:00

44 lines
1.9 KiB
TypeScript

// Server-only, in-memory sliding-window limiter — deliberately no Redis:
// this app runs as a single Coolify container, so a plain in-process Map
// is sufficient and needs zero new infra. Counters reset on redeploy/
// restart (acceptable — an attacker gets a few extra free attempts right
// after a deploy, not a meaningful window) and won't share state if this
// ever scales to multiple instances; revisit with a shared store then.
//
// Complements, doesn't replace, Payload's own built-in per-account login
// lockout (Customers collection, maxLoginAttempts: 5 / lockTime: 10min,
// Payload defaults) — that stops brute-forcing one account, this stops an
// IP hammering registration or spraying attempts across many accounts.
const attempts = new Map<string, number[]>();
// Prevents unbounded growth from IPs that hit a route once and never
// return — without this, `attempts` would grow forever on a low-traffic
// site that nonetheless gets scanned/crawled periodically.
const MAX_TRACKED_KEYS = 10_000;
export function checkRateLimit(key: string, { limit, windowMs }: { limit: number; windowMs: number }): boolean {
const now = Date.now();
const windowStart = now - windowMs;
const timestamps = (attempts.get(key) ?? []).filter((t) => t > windowStart);
if (timestamps.length >= limit) {
attempts.set(key, timestamps);
return false;
}
timestamps.push(now);
if (attempts.size >= MAX_TRACKED_KEYS && !attempts.has(key)) {
attempts.clear();
}
attempts.set(key, timestamps);
return true;
}
// Caddy sits in front of this app and sets X-Forwarded-For — falls back to
// a constant key (effectively a single shared bucket) if that's ever
// missing, e.g. local dev, rather than disabling rate limiting outright.
export function getClientIp(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
return forwarded?.split(",")[0]?.trim() || "unknown";
}