feat: implement rate limiting for API routes and enhance global error handling
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
// src/hooks.server.ts
|
// src/hooks.server.ts
|
||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle, HandleServerError } from '@sveltejs/kit';
|
||||||
import { building } from '$app/environment';
|
import { building } from '$app/environment';
|
||||||
import { auth } from '$lib/server/auth';
|
import { auth } from '$lib/server/auth';
|
||||||
import { svelteKitHandler } from 'better-auth/svelte-kit';
|
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 { user as userTable } from '$lib/server/db/auth.schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { ensureRootAdminRole } from '$lib/server/permissions';
|
import { ensureRootAdminRole } from '$lib/server/permissions';
|
||||||
|
import { checkRateLimit } from '$lib/server/rate-limit';
|
||||||
|
|
||||||
// ── Initialize the IRC DM handler once on server startup ──
|
// ── Initialize the IRC DM handler once on server startup ──
|
||||||
// This runs when the module is first imported (server boot).
|
// This runs when the module is first imported (server boot).
|
||||||
@@ -15,9 +16,30 @@ if (!building) {
|
|||||||
import('$lib/server/bancho/dm-handler')
|
import('$lib/server/bancho/dm-handler')
|
||||||
.then(({ initDMHandler }) => initDMHandler())
|
.then(({ initDMHandler }) => initDMHandler())
|
||||||
.catch((err) => console.warn('[Hooks] DM handler init skipped:', err.message));
|
.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 }) => {
|
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 });
|
const session = await auth.api.getSession({ headers: event.request.headers });
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
@@ -49,3 +71,17 @@ const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const handle: Handle = handleBetterAuth;
|
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.' };
|
||||||
|
};
|
||||||
|
|||||||
46
src/lib/server/rate-limit.ts
Normal file
46
src/lib/server/rate-limit.ts
Normal file
@@ -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<string, Entry>();
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user