diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 0def66e..4a34c98 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -4,6 +4,7 @@ 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'; /** @@ -62,6 +63,24 @@ export const handle: Handle = async ({ event, resolve }) => { 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); diff --git a/src/lib/redirects.server.ts b/src/lib/redirects.server.ts new file mode 100644 index 0000000..435cbe4 --- /dev/null +++ b/src/lib/redirects.server.ts @@ -0,0 +1,101 @@ +/** + * CMS-backed redirect store. + * + * Loads the public `/api/redirects` list (enabled only) and serves it as an + * exact-match lookup. Cache is in-memory with a TTL; a background refresh + * runs after the TTL expires so requests don't pay the network cost. + * + * Editors manage the list in the admin UI (`/admin/plugins/redirects`) — + * webhook-based invalidation is not wired up yet; the 60 s TTL is the + * eventual-consistency bound. + */ +import { getCmsBaseUrl } from './cms-source.server'; +import { logWarn } from './log.server'; + +type Redirect = { + id?: string; + from: string; + to: string; + status: 301 | 302; + enabled?: boolean; +}; + +type Index = Map; + +const ENV_NAME = 'windwiderstand'; +const TTL_MS = 60_000; + +let cache: Index = new Map(); +let loadedAt = 0; +let inflight: Promise | null = null; + +function normalize(path: string): string { + // Match leading-slash-required paths; strip trailing slash so /foo + // and /foo/ resolve to the same rule (the CMS stores exact paths). + if (!path.startsWith('/')) return path; + if (path.length > 1 && path.endsWith('/')) return path.slice(0, -1); + return path; +} + +async function fetchRedirects(): Promise { + const url = `${getCmsBaseUrl()}/api/redirects?_environment=${encodeURIComponent(ENV_NAME)}`; + const r = await fetch(url, { signal: AbortSignal.timeout(5_000) }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const data = (await r.json()) as unknown; + if (!Array.isArray(data)) return []; + return data.filter( + (x): x is Redirect => + typeof x === 'object' && + x !== null && + typeof (x as Redirect).from === 'string' && + typeof (x as Redirect).to === 'string', + ); +} + +function rebuildIndex(list: Redirect[]): Index { + const idx: Index = new Map(); + for (const r of list) { + if (r.enabled === false) continue; + const status = r.status === 302 ? 302 : 301; + idx.set(normalize(r.from), { to: r.to, status }); + } + return idx; +} + +async function refresh(): Promise { + try { + const list = await fetchRedirects(); + cache = rebuildIndex(list); + loadedAt = Date.now(); + } catch (err) { + logWarn('redirects.refresh', err); + // Keep old cache on failure — better stale than broken. + loadedAt = Date.now(); + } +} + +function ensureFresh(): Promise | void { + const stale = Date.now() - loadedAt > TTL_MS; + if (!stale) return; + // First load (cold start) blocks the request; subsequent refreshes run in + // the background so requests get the stale cache instantly. + if (loadedAt === 0) { + inflight ??= refresh().finally(() => { + inflight = null; + }); + return inflight; + } + if (!inflight) { + inflight = refresh().finally(() => { + inflight = null; + }); + } +} + +export async function lookupRedirect( + pathname: string, +): Promise<{ to: string; status: 301 | 302 } | null> { + const maybe = ensureFresh(); + if (maybe) await maybe; + return cache.get(normalize(pathname)) ?? null; +}