diff --git a/src/hooks.server.ts b/src/hooks.server.ts index c257085..1a2624a 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,5 +1,5 @@ // src/hooks.server.ts -import type { Handle } from '@sveltejs/kit'; +import type { Handle, HandleServerError } from '@sveltejs/kit'; import { building } from '$app/environment'; import { auth } from '$lib/server/auth'; import { svelteKitHandler } from 'better-auth/svelte-kit'; @@ -7,6 +7,7 @@ import { db } from '$lib/server/db'; import { user as userTable } from '$lib/server/db/auth.schema'; import { eq } from 'drizzle-orm'; import { ensureRootAdminRole } from '$lib/server/permissions'; +import { checkRateLimit } from '$lib/server/rate-limit'; // ── Initialize the IRC DM handler once on server startup ── // This runs when the module is first imported (server boot). @@ -15,9 +16,30 @@ if (!building) { import('$lib/server/bancho/dm-handler') .then(({ initDMHandler }) => initDMHandler()) .catch((err) => console.warn('[Hooks] DM handler init skipped:', err.message)); + + process.on('unhandledRejection', (reason) => { + console.error('[Unhandled Rejection]', reason); + }); + + process.on('uncaughtException', (err) => { + console.error('[Uncaught Exception]', err); + }); } const handleBetterAuth: Handle = async ({ event, resolve }) => { + // ── Rate limiting for API routes ── + // 200 req/min per IP — high enough for legit use, stops bots/scrapers. + if (event.url.pathname.startsWith('/api/')) { + const ip = event.getClientAddress(); + const { ok, retryAfter } = checkRateLimit(`api:${ip}`, 200, 60_000); + if (!ok) { + return new Response('Too Many Requests', { + status: 429, + headers: { 'Retry-After': String(retryAfter ?? 60), 'Content-Type': 'text/plain' } + }); + } + } + const session = await auth.api.getSession({ headers: event.request.headers }); if (session) { @@ -49,3 +71,17 @@ const handleBetterAuth: Handle = async ({ event, resolve }) => { }; export const handle: Handle = handleBetterAuth; + +// ── Global server error handler ── +// Catches unhandled errors from load functions, API routes, etc. +// Logs context for debugging; returns a safe message to the client. +export const handleError: HandleServerError = ({ error, event }) => { + const err = error as any; + console.error('[Server Error]', { + message: err?.message ?? String(error), + path: event.url.pathname, + method: event.request.method, + stack: err?.stack + }); + return { message: 'An unexpected error occurred.' }; +}; diff --git a/src/lib/server/rate-limit.ts b/src/lib/server/rate-limit.ts new file mode 100644 index 0000000..0a3cdc7 --- /dev/null +++ b/src/lib/server/rate-limit.ts @@ -0,0 +1,46 @@ +// src/lib/server/rate-limit.ts +// Simple fixed-window in-memory rate limiter. +// Good enough for a single-server deployment — swap for Redis if you ever scale horizontally. + +interface Entry { + count: number; + resetAt: number; +} + +const store = new Map(); + +// Prune expired entries every 5 minutes to avoid unbounded memory growth +setInterval( + () => { + const now = Date.now(); + for (const [key, entry] of store) { + if (now > entry.resetAt) store.delete(key); + } + }, + 5 * 60 * 1000 +).unref?.(); + +/** + * Returns `{ ok: true }` if the request is within the limit, + * or `{ ok: false, retryAfter }` (seconds) if it's exceeded. + */ +export function checkRateLimit( + key: string, + limit: number, + windowMs: number +): { ok: boolean; retryAfter?: number } { + const now = Date.now(); + const entry = store.get(key); + + if (!entry || now > entry.resetAt) { + store.set(key, { count: 1, resetAt: now + windowMs }); + return { ok: true }; + } + + if (entry.count >= limit) { + return { ok: false, retryAfter: Math.ceil((entry.resetAt - now) / 1000) }; + } + + entry.count++; + return { ok: true }; +}