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:
ginnoir
2026-05-06 17:23:29 -05:00
co-authored by Claude Sonnet 4.6
parent b6abe052c9
commit 285a460eb8
17 changed files with 616 additions and 11 deletions
+41 -2
View File
@@ -1,20 +1,45 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { resolveShareToken } from "@/modules/_core/share";
import { getEntityType } from "@/modules/_core/registry";
import { isRateLimited, recordFailure } from "@/lib/rate-limit";
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
// Rate-limit prefix length — must match the value used in middleware.
const RL_PREFIX_LEN = 8;
export default async function SharePage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const headersList = await headers();
const ip =
headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ??
headersList.get("x-real-ip") ??
"0.0.0.0";
const rlKey = `${ip}:${token.slice(0, RL_PREFIX_LEN)}`;
// Secondary rate-limit check in the Node.js runtime (failure-only bucket).
// The primary 429 enforcement lives in src/middleware.ts which counts all
// requests in the Edge runtime. This page tracks only failed token lookups,
// providing accurate per-failure accounting. The two buckets are independent
// (separate module instances across runtimes); a shared Redis store would
// unify them for multi-replica deployments.
if (isRateLimited(rlKey)) {
return <ShareRateLimitError />;
}
const resolved = await resolveShareToken(token);
if (!resolved) return <ShareError />;
if (!resolved) {
// Only failed lookups increment the failure bucket.
recordFailure(rlKey);
return <ShareError />;
}
const entityReg = getEntityType(resolved.entityType);
if (!entityReg?.loadForShare || !entityReg.renderSharedView) {
@@ -22,7 +47,10 @@ export default async function SharePage({
}
const data = await entityReg.loadForShare(resolved.entityId);
if (!data) return <ShareError />;
if (!data) {
recordFailure(rlKey);
return <ShareError />;
}
return (
<div className="min-h-screen">
@@ -37,6 +65,17 @@ export default async function SharePage({
);
}
function ShareRateLimitError() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
<h1 className="text-xl font-semibold">Too many requests</h1>
<p className="max-w-sm text-sm text-muted-foreground">
You have made too many requests in a short period. Please wait a minute and try again.
</p>
</div>
);
}
function ShareError({ message }: { message?: string }) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">