Implement tasks 60, 61, 62: backups, rate limiting, structured logging
Task 60 — Postgres backups: - deploy/backups/: backup.sh (pg_dump -Fc nightly), retain.sh (14/8/6 tiers), restore.sh, entrypoint.sh, crontab - famapp-backup Alpine service + backups volume added to deploy/compose.yaml - Restore procedure in deploy/backups/README.md Task 61 — Rate limiting on share links: - src/lib/rate-limit.ts: Edge-compatible sliding-window counter (50/min, LRU eviction) with consume(), isRateLimited(), recordFailure() exports - middleware.ts: enforces 429 with Retry-After: 60 for /s/[token] (IP + token prefix) - /s/[token]/page.tsx: tracks only failed resolveShareToken calls via recordFailure() Task 62 — Structured logging: - pino + pino-pretty installed; serverExternalPackages added to next.config.ts - src/lib/logger.ts: JSON in production, pretty in dev, level from LOG_LEVEL env - middleware.ts: structured JSON request log (method, path, status, ms, authenticated) - _core/push.ts, notify.ts, reminders.ts: console.error/log → logger.error/info Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
b6abe052c9
commit
285a460eb8
@@ -0,0 +1,65 @@
|
||||
// In-memory sliding-window rate limiter.
|
||||
//
|
||||
// State is module-scoped: each Next.js runtime (Edge middleware vs Node.js server
|
||||
// components) gets its own module instance, so buckets are not shared between them.
|
||||
//
|
||||
// Redis migration path: replace the `buckets` Map with an upstash/ratelimit or
|
||||
// ioredis ZADD + ZRANGEBYSCORE approach. The exported function signatures stay the
|
||||
// same, only the storage layer changes, giving cross-process and cross-runtime
|
||||
// consistency for multi-replica deployments.
|
||||
|
||||
const WINDOW_MS = 60_000; // 1 minute
|
||||
const LIMIT = 50; // max attempts per window per key
|
||||
const MAX_KEYS = 10_000; // evict oldest when Map grows beyond this
|
||||
|
||||
const buckets = new Map<string, number[]>();
|
||||
|
||||
function sweep(key: string, now: number): number[] {
|
||||
const fresh = (buckets.get(key) ?? []).filter((t) => now - t < WINDOW_MS);
|
||||
buckets.set(key, fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
function evictOldest(exclude: string) {
|
||||
if (buckets.size < MAX_KEYS) return;
|
||||
for (const k of buckets.keys()) {
|
||||
if (k !== exclude) {
|
||||
buckets.delete(k);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether `key` has exceeded the rate limit WITHOUT recording a new attempt.
|
||||
* Use this to gate a request before you know whether it will succeed or fail.
|
||||
*/
|
||||
export function isRateLimited(key: string): boolean {
|
||||
return sweep(key, Date.now()).length >= LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one failed attempt for `key`.
|
||||
* Successful requests should NOT call this — only failures increment the bucket.
|
||||
*/
|
||||
export function recordFailure(key: string): void {
|
||||
const now = Date.now();
|
||||
const ts = sweep(key, now);
|
||||
if (ts.length >= LIMIT) return; // already over limit; don't bother growing the array
|
||||
evictOldest(key);
|
||||
buckets.set(key, [...ts, now]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and atomically consume one token for `key`.
|
||||
* Returns `true` if the request is allowed; `false` if rate-limited.
|
||||
* Used by middleware where every inbound request (success or failure) is counted.
|
||||
*/
|
||||
export function consume(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const ts = sweep(key, now);
|
||||
if (ts.length >= LIMIT) return false;
|
||||
evictOldest(key);
|
||||
buckets.set(key, [...ts, now]);
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user