Files
esports/src/hooks.server.ts
2026-03-27 19:33:47 +01:00

180 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/hooks.server.ts
import type { Handle, HandleServerError } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { building } from '$app/environment';
import * as Sentry from '@sentry/sveltekit';
if (!building) {
Sentry.init({
dsn: process.env.PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0, // TODO: lower to 0.2–0.3 once past alpha
environment: process.env.NODE_ENV ?? 'development',
sendDefaultPii: true
});
}
import { auth } from '$lib/server/auth';
import { svelteKitHandler } from 'better-auth/svelte-kit';
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';
import { log } from '$lib/server/logger';
import { metrics } from '$lib/server/metrics';
// ── Initialize the IRC DM handler once on server startup ──
if (!building) {
import('$lib/server/bancho/dm-handler')
.then(({ initDMHandler }) => initDMHandler())
.catch((err) => log.dm.warn({ err: err.message }, 'DM handler init skipped'));
import('$lib/server/tournament/scheduler')
.then(({ startTournamentScheduler }) => startTournamentScheduler())
.catch((err) => log.tournament.warn({ err: err.message }, 'Tournament scheduler init skipped'));
import('$lib/server/discord/client')
.then(({ initDiscordClient }) => initDiscordClient())
.catch((err) => log.discord.warn({ err: err.message }, 'Discord client init skipped'));
process.on('unhandledRejection', (reason) => {
log.http.error({ err: reason }, 'Unhandled rejection');
});
process.on('uncaughtException', (err) => {
log.http.fatal({ err }, 'Uncaught exception');
});
}
/** Safe IP getter — returns null for internal requests missing the ADDRESS_HEADER */
function getIp(event: Parameters<Handle>[0]['event']): string | null {
try {
return event.getClientAddress();
} catch {
return null;
}
}
// ── Request logging middleware ──
// Paths that are too noisy to log every time (healthchecks, polling)
const SILENT_PATHS = new Set(['/api/health', '/api/queue']);
const handleRequestLogging: Handle = async ({ event, resolve }) => {
const requestId = crypto.randomUUID();
const start = performance.now();
event.locals.requestId = requestId;
const response = await resolve(event);
const duration = Math.round(performance.now() - start);
const path = event.url.pathname;
const status = response.status;
// Always count metrics, but only log interesting requests
metrics.requestCount++;
metrics.totalResponseTime += duration;
const isSilent = SILENT_PATHS.has(path) && status < 400;
if (!isSilent) {
const userId = event.locals.user?.id;
log.http.info(
{
requestId,
method: event.request.method,
path,
status,
duration,
userId: userId ?? null,
ip: getIp(event)
},
`${event.request.method} ${path} ${status} ${duration}ms`
);
}
if (duration > 1000) {
log.http.warn({ requestId, path, duration }, 'Slow request');
metrics.slowRequests++;
}
response.headers.set('X-Request-Id', requestId);
return response;
};
// ── Auth + rate limiting ──
const handleBetterAuth: Handle = async ({ event, resolve }) => {
// Rate limiting for API routes
if (event.url.pathname.startsWith('/api/')) {
const ip = getIp(event) ?? 'unknown';
// Stricter limit for auth endpoints: 10 req/min per IP
if (event.url.pathname.startsWith('/api/auth/')) {
const { ok, retryAfter } = checkRateLimit(`auth:${ip}`, 10, 60_000);
if (!ok) {
return new Response('Too Many Requests', {
status: 429,
headers: { 'Retry-After': String(retryAfter ?? 60), 'Content-Type': 'text/plain' }
});
}
}
// General API limit: 600 req/min per IP
const { ok, retryAfter } = checkRateLimit(`api:${ip}`, 600, 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) {
event.locals.session = session.session;
const dbUser = await db.query.user.findFirst({
where: eq(userTable.id, session.user.id)
});
let role = dbUser?.role ?? 'player';
if (dbUser) {
role = await ensureRootAdminRole({
id: dbUser.id,
email: dbUser.email,
role
});
}
event.locals.user = {
...session.user,
role
};
}
return svelteKitHandler({ event, resolve, auth, building });
};
export const handle: Handle = sequence(
Sentry.sentryHandle(),
handleRequestLogging,
handleBetterAuth
);
// ── Global server error handler ──
export const handleError: HandleServerError = Sentry.handleErrorWithSentry(({ error, event }) => {
const err = error instanceof Error ? error : new Error(String(error));
log.http.error(
{
err,
requestId: event.locals.requestId,
path: event.url.pathname,
method: event.request.method
},
'Unhandled server error'
);
return { message: 'An unexpected error occurred.' };
});