import type { Handle } from '@sveltejs/kit'; import { sequence } from '@sveltejs/kit/hooks'; import { handleErrorWithSentry } from '@sentry/sveltekit'; import * as Sentry from '@sentry/sveltekit'; import { randomBytes, createHmac } from 'node:crypto'; import { env } from '$env/dynamic/private'; import { env as pubEnv } from '$env/dynamic/public'; import { log } from '$lib/server/logger'; import { createUserSession, touchUserSession, isSessionRevoked, getUserById } from '$lib/server/pocketbase'; import { drain as drainPresignCache } from '$lib/server/presignCache'; import { NodeSDK } from '@opentelemetry/sdk-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'; import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs'; import { resourceFromAttributes } from '@opentelemetry/resources'; import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; import { paraglideMiddleware } from '$lib/paraglide/server'; // ─── OpenTelemetry server-side tracing + logs ───────────────────────────────── // No-op when OTEL_EXPORTER_OTLP_ENDPOINT is unset (e.g. local dev). const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; if (otlpEndpoint) { const sdk = new NodeSDK({ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? 'ui', [ATTR_SERVICE_VERSION]: pubEnv.PUBLIC_BUILD_VERSION ?? 'dev' }), traceExporter: new OTLPTraceExporter({ url: `${otlpEndpoint}/v1/traces` }), logRecordProcessors: [ new BatchLogRecordProcessor( new OTLPLogExporter({ url: `${otlpEndpoint}/v1/logs` }) ) ] }); sdk.start(); process.once('SIGTERM', () => sdk.shutdown().catch(() => {})); process.once('SIGINT', () => sdk.shutdown().catch(() => {})); } // ─── Sentry / GlitchTip server-side error tracking ──────────────────────────── // No-op when PUBLIC_GLITCHTIP_DSN is unset (e.g. local dev). if (pubEnv.PUBLIC_GLITCHTIP_DSN) { Sentry.init({ dsn: pubEnv.PUBLIC_GLITCHTIP_DSN, tracesSampleRate: 0.1, // Must match the release name used when uploading source maps in CI // (BUILD_VERSION injected by Dockerfile as PUBLIC_BUILD_VERSION). release: pubEnv.PUBLIC_BUILD_VERSION || undefined }); } export const handleError = handleErrorWithSentry(); // ─── Graceful shutdown ──────────────────────────────────────────────────────── // // When Docker/Kubernetes sends SIGTERM (or the user sends SIGINT), we: // 1. Set shuttingDown = true so new requests immediately receive 503. // 2. Flush/drain in-process caches (presign URL cache). // 3. Allow Node.js to exit naturally once in-flight requests finish. // // adapter-node does not provide a built-in hook for this, so we wire it here // in hooks.server.ts which runs in the server Node.js process. let shuttingDown = false; function shutdown(signal: string) { if (shuttingDown) return; shuttingDown = true; log.info('shutdown', `received ${signal}, draining in-flight requests`); drainPresignCache(); // Don't call process.exit() — let Node exit naturally once the event loop // is empty (adapter-node closes the HTTP server on its own). } process.once('SIGTERM', () => shutdown('SIGTERM')); process.once('SIGINT', () => shutdown('SIGINT')); const SESSION_COOKIE = 'libnovel_session'; const AUTH_COOKIE = 'libnovel_auth'; const ONE_YEAR = 60 * 60 * 24 * 365; const AUTH_SECRET = env.AUTH_SECRET ?? 'dev_secret_change_in_production'; // ─── Token helpers ──────────────────────────────────────────────────────────── /** * Sign a payload string with HMAC-SHA256 using AUTH_SECRET. * Returns ".". */ export function signToken(payload: string): string { const sig = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex'); return `${payload}.${sig}`; } /** * Verify a signed token. Returns the payload string on success, null on failure. */ export function verifyToken(token: string): string | null { const lastDot = token.lastIndexOf('.'); if (lastDot < 0) return null; const payload = token.slice(0, lastDot); const expected = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex'); const actual = token.slice(lastDot + 1); // constant-time comparison if (expected.length !== actual.length) return null; let diff = 0; for (let i = 0; i < expected.length; i++) { diff |= expected.charCodeAt(i) ^ actual.charCodeAt(i); } return diff === 0 ? payload : null; } /** * Create a signed auth token for a user. * Payload format: ":::" * authSessionId uniquely identifies this login session (for revocation). */ export function createAuthToken(userId: string, username: string, role: string, authSessionId: string): string { return signToken(`${userId}:${username}:${role}:${authSessionId}`); } /** * Parse a verified auth token into user data. Returns null if invalid. * Supports both old format (3 segments) and new format (4 segments). */ export function parseAuthToken(token: string): { id: string; username: string; role: string; authSessionId: string } | null { const payload = verifyToken(token); if (!payload) return null; const parts = payload.split(':'); // New format: userId:username:role:authSessionId (4 parts) // Old format: userId:username:role (3 parts — legacy tokens before session tracking) if (parts.length < 3) return null; const id = parts[0]; const username = parts[1]; const role = parts[2]; const authSessionId = parts[3] ?? ''; // empty string for legacy tokens if (!id || !username) return null; return { id, username, role, authSessionId }; } // ─── Hook ───────────────────────────────────────────────────────────────────── function getTextDirection(locale: string): string { // All supported locales (en, ru, id, pt, fr) are LTR return 'ltr'; } const paraglideHandle: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => { event.request = localizedRequest; return resolve(event, { transformPageChunk: ({ html }) => html.replace('%lang%', locale).replace('%dir%', getTextDirection(locale)) }); }); const appHandle: Handle = async ({ event, resolve }) => { // During graceful shutdown, reject new requests immediately so the load // balancer / Docker health-check can drain existing connections. if (shuttingDown) { return new Response('Service shutting down', { status: 503 }); } // Anonymous session cookie (for reading progress) let sessionId = event.cookies.get(SESSION_COOKIE) ?? ''; if (!sessionId) { sessionId = randomBytes(16).toString('hex'); event.cookies.set(SESSION_COOKIE, sessionId, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: ONE_YEAR }); } event.locals.sessionId = sessionId; // Auth cookie → resolve logged-in user const authToken = event.cookies.get(AUTH_COOKIE); if (authToken) { const user = parseAuthToken(authToken); if (!user) { log.warn('auth', 'auth cookie present but failed to parse (malformed or tampered)'); event.locals.user = null; } else { // Validate session against DB (only for new-format tokens with authSessionId) let sessionValid = true; if (user.authSessionId) { try { const revoked = await isSessionRevoked(user.authSessionId); if (revoked) { log.info('auth', 'auth cookie references revoked session', { userId: user.id, authSessionId: user.authSessionId }); sessionValid = false; // Clear the invalid cookie event.cookies.delete(AUTH_COOKIE, { path: '/' }); } else { // Best-effort: update last_seen in the background touchUserSession(user.authSessionId).catch(() => {}); } } catch (err) { // DB error — fail open to avoid locking everyone out log.warn('auth', 'session check failed (fail open)', { err: String(err) }); } } event.locals.user = sessionValid ? user : null; } } else { event.locals.user = null; } // ── isPro: read fresh from DB so role changes take effect without re-login ── if (event.locals.user) { try { const dbUser = await getUserById(event.locals.user.id); event.locals.isPro = dbUser?.role === 'pro' || dbUser?.role === 'admin'; } catch { event.locals.isPro = false; } } else { event.locals.isPro = false; } return resolve(event); }; export const handle = sequence(paraglideHandle, appHandle);