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
+57
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user