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">
+28
View File
@@ -0,0 +1,28 @@
import pino from "pino";
// Structured JSON logger for Node.js server code (server components, server actions,
// background workers). NOT available in the Edge runtime (middleware) — use
// console.log with JSON.stringify there instead.
//
// Secrets are never passed as log fields. Any field named `password`, `secret`,
// `token`, or `key` is explicitly excluded by callers. Sensitive env vars
// (AUTH_SECRET, VAPID_PRIVATE_KEY, etc.) must not appear in log payloads.
//
// Log level is controlled by the LOG_LEVEL env var (default: info).
// In development, pino-pretty formats output with colour for readability.
// In production, single-line JSON goes to stdout and is collected by Docker.
const level = process.env.LOG_LEVEL ?? "info";
const logger = pino(
{
level,
base: { pid: undefined, hostname: undefined },
timestamp: pino.stdTimeFunctions.isoTime,
},
process.env.NODE_ENV === "production"
? undefined
: pino.transport({ target: "pino-pretty", options: { colorize: true } }),
);
export default logger;
+65
View File
@@ -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;
}
+57 -1
View File
@@ -1,12 +1,43 @@
import { NextResponse, type NextRequest } from "next/server";
import { consume } from "@/lib/rate-limit";
const PUBLIC_PREFIXES = ["/api/auth/", "/s/"];
const PUBLIC_PATHS = new Set(["/login"]);
const SESSION_COOKIE_NAMES = ["authjs.session-token", "__Secure-authjs.session-token"];
// Token-prefix length used as part of the rate-limit bucket key.
const RL_PREFIX_LEN = 8;
export function middleware(request: NextRequest) {
const start = performance.now();
const response = route(request);
logRequest(request, response.status, Math.round(performance.now() - start));
return response;
}
function route(request: NextRequest): NextResponse {
const { pathname } = request.nextUrl;
if (PUBLIC_PATHS.has(pathname) || PUBLIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
// Rate-limit the public share-link viewer (/s/<token>).
// The rate limiter runs in the Edge runtime and has its own module-level bucket
// Map (separate from the Node.js runtime used by page components). All requests
// to /s/<token> are counted here regardless of whether the token resolves; page
// components additionally track per-failure counts via recordFailure() for
// accurate auditing. Switch to Redis for cross-runtime / multi-replica accuracy.
if (pathname.startsWith("/s/") && pathname.length > 3) {
const token = pathname.slice(3); // strip "/s/"
const ip = clientIp(request);
const key = `${ip}:${token.slice(0, RL_PREFIX_LEN)}`;
if (!consume(key)) {
return new NextResponse("Too Many Requests", {
status: 429,
headers: { "Retry-After": "60", "Content-Type": "text/plain" },
});
}
}
if (PUBLIC_PATHS.has(pathname) || PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
@@ -17,6 +48,14 @@ export function middleware(request: NextRequest) {
return NextResponse.redirect(loginUrl);
}
function clientIp(request: NextRequest): string {
return (
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip") ??
"0.0.0.0"
);
}
function hasSessionCookie(request: NextRequest) {
return request.cookies
.getAll()
@@ -27,6 +66,23 @@ function hasSessionCookie(request: NextRequest) {
);
}
// Structured request log — output as JSON so it's machine-readable in production.
// pino is not available in the Edge runtime; console.log goes to server stdout.
// userId is omitted: decoding the session token requires a DB lookup unavailable here.
function logRequest(request: NextRequest, status: number, ms: number) {
const authenticated = hasSessionCookie(request);
const entry = {
level: "info",
time: Date.now(),
method: request.method,
path: request.nextUrl.pathname,
status,
ms,
authenticated,
};
console.log(JSON.stringify(entry));
}
export const config = {
matcher: [
// Skip Next.js internals and static files
+3 -2
View File
@@ -1,5 +1,6 @@
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import logger from "@/lib/logger";
import { notifications, users } from "./schema";
import { sendPush } from "./push";
@@ -25,7 +26,7 @@ export async function notify(userId: string, payload: NotifyPayload) {
if (channels.includes("push") && user.notifPush && pushEnabled) {
await sendPush(userId, payload).catch((err) =>
console.error("[famapp] push channel failed:", err),
logger.error({ err }, "push channel failed"),
);
}
@@ -46,7 +47,7 @@ export async function notify(userId: string, payload: NotifyPayload) {
method: "POST",
headers: { Title: payload.title, "Content-Type": "text/plain" },
body: payload.body,
}).catch((err) => console.error("[famapp] ntfy delivery failed:", err));
}).catch((err) => logger.error({ err }, "ntfy delivery failed"));
}
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
import webPush from "web-push";
import { and, eq, inArray } from "drizzle-orm";
import { db } from "@/lib/db";
import logger from "@/lib/logger";
import { pushSubscriptions } from "./schema";
function ensureVapidConfigured() {
@@ -40,7 +41,7 @@ export async function sendPush(
if (status === 404 || status === 410) {
staleIds.push(sub.id);
} else {
console.error("[famapp] push delivery failed:", err);
logger.error({ err }, "push delivery failed");
}
}
}),
+5 -4
View File
@@ -1,5 +1,6 @@
import { and, eq, inArray, isNull, lte, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import logger from "@/lib/logger";
import { reminders } from "./schema";
import { notify } from "./notify";
@@ -67,7 +68,7 @@ export async function tickReminders() {
}
});
} catch (err) {
console.error("[famapp] reminder tick error:", err);
logger.error({ err }, "reminder tick error");
return;
}
@@ -82,7 +83,7 @@ export async function tickReminders() {
channels: ["push", "inapp"],
});
} catch (err) {
console.error("[famapp] reminder delivery failed:", reminder.id, err);
logger.error({ reminderId: reminder.id, err }, "reminder delivery failed");
}
}),
);
@@ -93,7 +94,7 @@ let workerTimer: ReturnType<typeof setInterval> | null = null;
export function startReminderWorker() {
if (workerTimer) return;
workerTimer = setInterval(() => {
tickReminders().catch((err) => console.error("[famapp] reminder worker uncaught:", err));
tickReminders().catch((err) => logger.error({ err }, "reminder worker uncaught error"));
}, 30_000);
console.log("[famapp] reminder worker started (30s tick)");
logger.info("reminder worker started (30s tick)");
}