Files
familienarchiv/frontend/src/hooks.server.ts
Marcel 9f1e2c9ff5 refactor(auth): hooks.server.ts re-throws redirects via isRedirect()
Replace the duck-typed `status in error && location in error` check with the
official SvelteKit guard. Fragile against minor-version error-shape changes
becomes a one-liner against a typed helper. Addresses PR #612 / Felix F1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 22:45:46 +02:00

143 lines
4.5 KiB
TypeScript

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);
};
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) {
// Auth endpoints that establish/check their own credentials manage cookies themselves;
// don't double-inject a stale fa_session.
const PUBLIC_API_PATHS = [
'/api/auth/login',
'/api/auth/logout',
'/api/auth/forgot-password',
'/api/auth/reset-password',
'/api/auth/invite/',
'/api/auth/register'
];
if (PUBLIC_API_PATHS.some((p) => request.url.includes(p))) {
return fetch(request);
}
const sessionId = event.cookies.get('fa_session');
if (!sessionId) {
return new Response('Unauthorized', { status: 401 });
}
// Clone first so the body stream is preserved on the new Request.
const cloned = request.clone();
const modified = new Request(cloned, {
headers: {
...Object.fromEntries(cloned.headers),
Cookie: `fa_session=${sessionId}`
}
});
return fetch(modified);
}
return fetch(request);
};
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 };
});