import { getNormalizedTranslations } 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 { lookupRedirect } from '$lib/redirects.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); } // CMS-verwaltete Redirects (Admin UI → /admin/plugins/redirects). // Editor-Regeln gewinnen gegen hardcoded Legacy-Patterns; nur GET, sonst // brechen POST-Forms beim Pfadwechsel. if (event.request.method === 'GET') { const cmsRedirect = await lookupRedirect(event.url.pathname); if (cmsRedirect) { const search = event.url.search; const dest = cmsRedirect.to.startsWith('http') || !search ? cmsRedirect.to : `${cmsRedirect.to}${search}`; return new Response(null, { status: cmsRedirect.status, headers: { location: dest }, }); } } // 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 { event.locals.translations = await getNormalizedTranslations( TRANSLATION_BUNDLE_SLUG, { locale: 'de' }, ); } 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'); // Vite-versioned assets: content-hashed filenames → safe to cache 1 year. if (event.url.pathname.startsWith('/_app/immutable/')) { response.headers.set('cache-control', 'public, max-age=31536000, immutable'); } else 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; };