import { getTranslationBundleBySlug } from '$lib/cms'; import { isCmsAvailable } from '$lib/cms-health.server'; import { maintenanceResponse } from '$lib/maintenance.server'; import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants'; import { env as envPublic } from '$env/dynamic/public'; import { logWarn } from '$lib/log.server'; import type { Handle } from '@sveltejs/kit'; /** * Page-Routen: kurze Edge-Cache + SWR. Webhook purged App-Cache, * Edge läuft via stale-while-revalidate auf den warmen App-Cache. */ const PAGE_CACHE_CONTROL = 'public, max-age=0, s-maxage=30, stale-while-revalidate=300'; const ASSET_EXT_RE = /\.(ico|png|jpe?g|webp|gif|svg|css|js|mjs|map|woff2?|ttf|otf|txt|xml|json|pdf)$/i; function isCacheablePath(pathname: string): boolean { if (pathname.startsWith('/api/')) return false; if (pathname.startsWith('/cms-images/')) return false; if (pathname.startsWith('/_app/')) return false; return true; } /** Pfade auf denen wir vor dem Resolve einen CMS-Health-Check machen. */ function shouldHealthCheck(pathname: string): boolean { if (pathname.startsWith('/api/')) return false; if (pathname.startsWith('/_app/')) return false; if (pathname.startsWith('/cms-images/')) return false; if (pathname.startsWith('/cms-files/')) return false; if (pathname === '/sitemap.xml' || pathname === '/robots.txt') return false; if (ASSET_EXT_RE.test(pathname)) return false; return true; } function isMaintenanceSimulation(pathname: string, search: URLSearchParams): boolean { if (pathname === '/maintenance' || pathname === '/maintenance/') return true; if (search.has('cms_down')) return true; return false; } /** * Legacy /blog/* + /p/* Pfade auf das neue /posts/* Schema mappen. * Liefert Ziel-Pfad oder null. Permanente 301-Redirects → werden vom * Browser/CDN gecacht, daher kein Render-Aufruf nötig. */ function legacyRedirectTarget(pathname: string): string | null { if (pathname === '/blog' || pathname === '/blog/') return '/posts/'; const m1 = pathname.match(/^\/blog\/page\/([^/]+)\/?$/); if (m1) return `/posts/page/${m1[1]}/`; const m2 = pathname.match(/^\/blog\/tag\/([^/]+)\/?$/); if (m2) return `/posts/tag/${m2[1]}/`; const m3 = pathname.match(/^\/p\/([^/]+)\/?$/); if (m3) return `/post/${m3[1]}/`; return null; } export const handle: Handle = async ({ event, resolve }) => { // Simulation: erlaubt manuelles Triggern der Maintenance-Seite ohne // CMS abzuschalten. /maintenance oder ?cms_down=1. if (isMaintenanceSimulation(event.url.pathname, event.url.searchParams)) { return maintenanceResponse(200); } // Legacy-Pfade (/blog, /p) → /posts, /post. Vor Health-Check, weil // SvelteKit-Routing/CMS-Calls dafür unnötig. const redirectTo = legacyRedirectTarget(event.url.pathname); if (redirectTo) { const dest = `${redirectTo}${event.url.search}`; return new Response(null, { status: 301, headers: { location: dest } }); } // Echter Ausfall: vor dem ersten CMS-Call prüfen ob die API antwortet. // Cached, daher kein Roundtrip pro Request. if (event.request.method === 'GET' && shouldHealthCheck(event.url.pathname)) { const ok = await isCmsAvailable(); if (!ok) return maintenanceResponse(503); } try { const bundle = await getTranslationBundleBySlug(TRANSLATION_BUNDLE_SLUG, { locale: 'de' }); if (bundle?.strings && typeof bundle.strings === 'object') { const normalized: Record = {}; for (const [key, val] of Object.entries(bundle.strings)) { const v = val as unknown; const str = typeof v === 'string' ? v : typeof v === 'object' && v !== null && 'value' in v ? (v as { value?: unknown }).value : (v as { text?: unknown })?.text ?? (v as { label?: unknown })?.label; if (typeof str === 'string') normalized[key] = str; } event.locals.translations = normalized; } else { event.locals.translations = {}; } } catch (err) { event.locals.translations = {}; logWarn('hooks.translations', err); } const response = await resolve(event, { preload: ({ type, path }) => { if (type === 'js' || type === 'css') return true; if (type === 'font') { return /inter-latin-(400|700)-normal\.[a-f0-9]*\.?woff2$/i.test(path); } return false; }, }); const isPreview = event.url.searchParams.has('preview') || event.url.searchParams.has('preview_draft'); const isPreviewDraft = event.url.searchParams.has('preview_draft'); if ( event.request.method === 'GET' && isCacheablePath(event.url.pathname) && !isPreview && !response.headers.has('cache-control') ) { response.headers.set('cache-control', PAGE_CACHE_CONTROL); } if ( event.request.method === 'GET' && !response.headers.has('x-robots-tag') && (response.headers.get('content-type') ?? '').includes('text/html') ) { response.headers.set( 'x-robots-tag', isPreview ? 'noindex, nofollow' : 'index, follow, max-image-preview:large, max-snippet:-1', ); } // Live-preview: allow the admin iframe to embed this response. The admin // origin is configured via PUBLIC_PREVIEW_PARENT_ORIGIN; when unset we fall // back to wildcard (dev only). Outside live-preview we leave existing // headers alone so production hardening (X-Frame-Options / CSP from Caddy) // stays intact. if (isPreviewDraft) { const parent = (envPublic.PUBLIC_PREVIEW_PARENT_ORIGIN ?? '').trim(); response.headers.set( 'content-security-policy', `frame-ancestors 'self' ${parent || '*'}`, ); response.headers.delete('x-frame-options'); } return response; };