import * as Sentry from '@sentry/sveltekit'; import { isRedirect, redirect, type Handle, type HandleFetch } from '@sveltejs/kit'; import { paraglideMiddleware } from '$lib/paraglide/server'; import { sequence } from '@sveltejs/kit/hooks'; import { env } from 'process'; import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime'; import { detectLocale } from '$lib/shared/server/locale'; // VITE_SENTRY_DSN is a write-only ingest key — it can POST events to GlitchTip // but cannot read them. Safe to include in the client bundle per Sentry security model. Sentry.init({ dsn: import.meta.env.VITE_SENTRY_DSN, environment: import.meta.env.MODE, tracesSampleRate: 0.1, sendDefaultPii: false, enabled: !!import.meta.env.VITE_SENTRY_DSN }); const PUBLIC_PATHS = [ '/login', '/logout', '/forgot-password', '/reset-password', '/register', '/hilfe/transkription' // prerendered help page — must be reachable without an auth cookie ]; const handleLocaleDetection: Handle = ({ event, resolve }) => { if (!event.cookies.get(cookieName)) { const locale = detectLocale(event.request.headers.get('accept-language') ?? ''); if (locale) { event.cookies.set(cookieName, locale, { path: '/', sameSite: 'lax', maxAge: cookieMaxAge, httpOnly: false }); } } return resolve(event); }; const handleAuth: Handle = async ({ event, resolve }) => { const isPublic = PUBLIC_PATHS.some((p) => event.url.pathname.startsWith(p)); if (!isPublic && !event.locals.user) { throw redirect(302, '/login'); } return resolve(event); }; const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => { event.request = request; return resolve(event, { transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale) }); }); const userGroup: Handle = async ({ event, resolve }) => { // One-off cleanup of the legacy Basic-credentials cookie from before the Spring Session migration (#523). if (event.cookies.get('auth_token')) { event.cookies.delete('auth_token', { path: '/' }); } const sessionId = event.cookies.get('fa_session'); if (!sessionId) { return resolve(event); } try { const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; const response = await fetch(`${apiUrl}/api/users/me`, { headers: { Cookie: `fa_session=${sessionId}` } }); if (response.ok) { event.locals.user = await response.json(); } else if (response.status === 401) { // Backend rejected the session (expired or invalidated). Drop the stale // cookie and surface the reason on the login page. PUBLIC_PATHS check // avoids a redirect loop if the user is already on /login. event.cookies.delete('fa_session', { path: '/' }); const isPublic = PUBLIC_PATHS.some((p) => event.url.pathname.startsWith(p)); if (!isPublic) { throw redirect(302, '/login?reason=expired'); } } } catch (error) { // Re-throw SvelteKit redirects (e.g. the /login?reason=expired throw above) // using the official guard rather than duck-typing on the error shape. if (isRedirect(error)) throw error; console.error('Error fetching user in hook:', error); } return resolve(event); }; const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); // Auth endpoints that establish/check their own credentials — skip fa_session injection // but still need CSRF tokens on mutating requests. const PUBLIC_API_PATHS = [ '/api/auth/login', '/api/auth/logout', '/api/auth/forgot-password', '/api/auth/reset-password', '/api/auth/invite/', '/api/auth/register' ]; export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/'); if (!isApi) return fetch(request); const isMutating = MUTATING_METHODS.has(request.method); const isPublicAuthApi = PUBLIC_API_PATHS.some((p) => request.url.includes(p)); const sessionId = !isPublicAuthApi ? event.cookies.get('fa_session') : null; if (!isPublicAuthApi && !sessionId) { return new Response('Unauthorized', { status: 401 }); } // Read the browser's XSRF-TOKEN cookie; fall back to a fresh UUID for the // double-submit cookie pattern (both cookie and header must match — no server secret). const xsrfToken = isMutating ? (event.cookies.get('XSRF-TOKEN') ?? crypto.randomUUID()) : null; const cookieParts: string[] = []; if (sessionId) cookieParts.push(`fa_session=${sessionId}`); if (xsrfToken) cookieParts.push(`XSRF-TOKEN=${xsrfToken}`); if (cookieParts.length === 0 && !xsrfToken) { return fetch(request); } // Clone first so the body stream is preserved on the new Request. const cloned = request.clone(); const extraHeaders: Record = {}; if (cookieParts.length > 0) extraHeaders['Cookie'] = cookieParts.join('; '); if (xsrfToken) extraHeaders['X-XSRF-TOKEN'] = xsrfToken; const modified = new Request(cloned, { headers: { ...Object.fromEntries(cloned.headers), ...extraHeaders } }); return fetch(modified); }; export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide); export const handleError = Sentry.handleErrorWithSentry(() => { const errorId = Sentry.lastEventId() ?? crypto.randomUUID(); return { message: 'An unexpected error occurred', errorId }; });