Production hardening: error pages, rate limiting, metadata
- Custom not-found (404) + error / global-error boundaries, styled with
the public design system; raw errors logged, never shown to users.
- In-process rate limiter (src/lib/rate-limit.ts) wired into the abuse-
prone flows: login (10/5min/IP), register (5/10min/IP), password-reset
request (3/15min/IP), keyed by the proxy-forwarded client IP.
- SEO/metadata: root generateMetadata sets a `%s · {hotel}` title
template from the live hotel_name; dynamic generateMetadata on
news/[slug] (article title + excerpt) and u/[username] (name + motto);
static titles on 12 primary public pages.
- env.ts: added the vars introduced since (PASSWORD_HASH, OPENAI_API_KEY,
DISCORD_WEBHOOK_URL, ALERT_EMAIL, PAYPAL_*) so env stays authoritative.
Verified on the prod server: /missing → 404 card, news title renders
"News · Habbo". tsc 0, vitest 49/49, next build 0.
This commit is contained in:
1 parent
e9ea19795a
commit
6f15e0c8a3
23 files changed
+276
-5
No files matched your search
@@ -6,6 +6,7 @@ import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
||||
import { checkLogin } from "@/lib/auth/password";
|
||||
import { verifyTotp } from "@/lib/auth/totp";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { env } from "@/env";
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
@@ -24,6 +25,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
const password = String(credentials?.password ?? "");
|
||||
if (!username || !password) return null;
|
||||
|
||||
// Throttle login attempts per IP (10 per 5 min) against credential stuffing.
|
||||
if (!rateLimit(`login:${await clientIp()}`, 10, 5 * 60_000).ok) return null;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
if (!user) return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
/**
|
||||
* Tiny in-process fixed-window rate limiter for abuse-prone server actions
|
||||
* (register, password reset, login). It's per-node (not shared across
|
||||
* instances) — fine for a single-server retro hotel; swap for Redis if you
|
||||
* ever scale out. Keys are typically `${action}:${ip}`.
|
||||
*/
|
||||
type Bucket = { count: number; resetAt: number };
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
export interface RateLimitResult {
|
||||
ok: boolean;
|
||||
/** Seconds until the window resets (0 when allowed). */
|
||||
retryAfter: number;
|
||||
}
|
||||
|
||||
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
|
||||
const now = Date.now();
|
||||
|
||||
// Opportunistic cleanup so the map can't grow without bound.
|
||||
if (buckets.size > 5000) {
|
||||
for (const [k, b] of buckets) if (now >= b.resetAt) buckets.delete(k);
|
||||
}
|
||||
|
||||
const bucket = buckets.get(key);
|
||||
if (!bucket || now >= bucket.resetAt) {
|
||||
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return { ok: true, retryAfter: 0 };
|
||||
}
|
||||
if (bucket.count >= limit) {
|
||||
return { ok: false, retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)) };
|
||||
}
|
||||
bucket.count += 1;
|
||||
return { ok: true, retryAfter: 0 };
|
||||
}
|
||||
|
||||
/** Best-effort client IP from the proxy headers our edge proxy forwards. */
|
||||
export async function clientIp(): Promise<string> {
|
||||
try {
|
||||
const h = await headers();
|
||||
return (
|
||||
h.get("x-real-client-ip") ??
|
||||
h.get("cf-connecting-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
h.get("x-real-ip") ??
|
||||
"0.0.0.0"
|
||||
);
|
||||
} catch {
|
||||
return "0.0.0.0";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user