1import type { Handle } from '@sveltejs/kit';
2import { users } from '$lib/db/convex.server';
3import { handleRateLimit } from '$lib/api/rate-limiter';
4import { createLogger } from '$lib/utils/logger.server';
5import { ensureDefaultChatRoom } from '$lib/utils/chat.server';
6import { validateSessionToken } from '$lib/api/session.server';
7import { setSessionTokenCookie, deleteSessionTokenCookie } from '$lib/api/session.cookie';
8
9const log = createLogger('hooks-server');
10
11// Add security headers to response
12function addSecurityHeaders(response: Response): Response {
13 const headers = new Headers(response.headers);
14 headers.set('X-Frame-Options', 'DENY');
15 headers.set('X-Content-Type-Options', 'nosniff');
16 headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
17 headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
18 headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
19
20 return new Response(response.body, {
21 status: response.status,
22 statusText: response.statusText,
23 headers
24 });
25}
26
27// Set all users to offline on server start (using Convex)
28async function setAllUsersOffline() {
29 try {
30 const result = await users.setAllOffline();
31 log.info('All users set to offline on server start', { updated: result.updated });
32 } catch (error: unknown) {
33 log.error('Failed to set users offline on server start:', { error });
34 }
35}
36
37// Initialize server lazily on the first API request
38let initPromise: Promise<void> | null = null;
39let lastFailedAttempt = 0;
40let failureCount = 0;
41const BASE_COOLDOWN = 5000; // 5 seconds
42const MAX_COOLDOWN = 60000; // 1 minute max
43
44function initializeServer(): Promise<void> {
45 if (initPromise) return initPromise;
46
47 // Exponential backoff after failures
48 if (lastFailedAttempt > 0) {
49 const cooldown = Math.min(BASE_COOLDOWN * Math.pow(2, failureCount - 1), MAX_COOLDOWN);
50 if (Date.now() - lastFailedAttempt < cooldown) {
51 return Promise.resolve();
52 }
53 }
54
55 initPromise = (async () => {
56 try {
57 log.info('Initializing server...');
58 await ensureDefaultChatRoom();
59 await setAllUsersOffline();
60 log.info('Server initialized successfully');
61 lastFailedAttempt = 0;
62 failureCount = 0;
63 } catch (error: unknown) {
64 log.error('Failed to initialize server:', { error, failureCount: failureCount + 1 });
65 lastFailedAttempt = Date.now();
66 failureCount++;
67 initPromise = null;
68 }
69 })();
70
71 return initPromise;
72}
73
74export const handle: Handle = async ({ event, resolve }) => {
75 const path = event.url.pathname;
76
77 // Skip initialization for static assets and non-API routes
78 const needsInit = path.startsWith('/api/') && !path.startsWith('/api/health');
79 if (needsInit) {
80 await initializeServer();
81 }
82
83 log.debug('Handling request', { path });
84
85 // CSRF protection: validate Origin header for state-changing requests
86 if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
87 const origin = event.request.headers.get('origin');
88
89 if (!origin) {
90 // Browsers always send Origin on state-changing requests.
91 // Missing Origin means non-browser client (curl, scripts).
92 log.warn('CSRF check failed - missing origin header', {
93 method: event.request.method,
94 path: event.url.pathname
95 });
96 return new Response('Forbidden', { status: 403 });
97 }
98
99 try {
100 // event.url comes from ORIGIN in production, so proxies rewriting Host do not matter
101 const originUrl = new URL(origin);
102 if (originUrl.origin !== event.url.origin) {
103 log.warn('CSRF check failed - origin mismatch', {
104 origin: originUrl.origin,
105 expected: event.url.origin
106 });
107 return new Response('Forbidden', { status: 403 });
108 }
109 } catch {
110 log.warn('CSRF check failed - invalid origin', { origin });
111 return new Response('Forbidden', { status: 403 });
112 }
113 }
114
115 // Get session token from cookies
116 const token = event.cookies.get('session') ?? null;
117
118 // Validate session token if token exists
119 if (token) {
120 const { session, user } = await validateSessionToken(token);
121 if (session) {
122 log.debug('Valid session found', { user: user?.nickname });
123 setSessionTokenCookie(event, token, session.expiresAt);
124 event.locals.session = session;
125 event.locals.user = user;
126 } else {
127 log.debug('Invalid session token');
128 deleteSessionTokenCookie(event);
129 event.locals.session = null;
130 event.locals.user = null;
131 }
132 }
133
134 // Identify public chat requests if there is no valid session
135 const isPublicChatRequest =
136 event.url.pathname.startsWith('/api/chat/messages') && event.request.method === 'GET';
137
138 if (isPublicChatRequest && !event.locals.session) {
139 log.debug('Public request accessed', { path: event.url.pathname });
140 const response = await resolve(event);
141 return addSecurityHeaders(response);
142 }
143
144 // Public routes that don't require authentication
145 const publicRoutes = [
146 '/',
147 '/api/health',
148 '/api/session/login',
149 '/api/session/validate',
150 '/api/register',
151 '/login',
152 '/register'
153 ];
154
155 // Handle public routes
156 if (publicRoutes.includes(event.url.pathname)) {
157 log.debug('Public route accessed', { path: event.url.pathname });
158 const response = await resolve(event);
159 return addSecurityHeaders(response);
160 }
161
162 // Apply rate limiting for non-public endpoints
163 if (!isPublicChatRequest) {
164 const rateLimitResult = await handleRateLimit(event);
165 if (rateLimitResult?.status === 429) {
166 return new Response('Too Many Requests', {
167 status: 429,
168 headers: {
169 'Retry-After': '60'
170 }
171 });
172 }
173 }
174
175 const response = await resolve(event);
176 return addSecurityHeaders(response);
177};