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:
Simo committed 2026-06-28 20:12:12 +02:00
1 parent e9ea19795a
commit 6f15e0c8a3
23 files changed
+276 -5

No files matched your search

+4
View File
@@ -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;
+52
View File
@@ -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";
}
}