/** * Die eigenständigen Seiten des Portals. * * Drei rechtliche Pflichtseiten sowie zwei Seiten, die § 4 BITV 2.0 von * öffentlichen Stellen verlangt: Erläuterungen in Leichter Sprache und in * Deutscher Gebärdensprache. */ export const SITE_PAGES = [ "imprint", "privacy", "accessibility", "easyLanguage", "signLanguage", ] as const /** Bezeichner einer Seite. */ export type SitePageKey = (typeof SITE_PAGES)[number] /** Überschrift und URL je Seite – für Fußzeile, Admin und die Seiten selbst. */ export const SITE_PAGE_META: Record< SitePageKey, { title: string; short: string; path: string; hint: string } > = { imprint: { title: "Impressum", short: "Impressum", path: "/impressum", hint: "Anbieterkennzeichnung nach § 5 DDG", }, privacy: { title: "Datenschutzerklärung", short: "Datenschutz", path: "/datenschutz", hint: "Datenverarbeitung durch dieses Portal selbst", }, accessibility: { title: "Erklärung zur Barrierefreiheit", short: "Barrierefreiheit", path: "/barrierefreiheit", hint: "Pflichtangabe öffentlicher Stellen nach § 14 LBGG Schleswig-Holstein, jährlich zu überprüfen", }, easyLanguage: { title: "Leichte Sprache", short: "Leichte Sprache", path: "/leichte-sprache", hint: "Pflichtangabe nach § 4 BITV 2.0: wesentliche Inhalte, Navigation und Barrierefreiheitserklärung in Leichter Sprache", }, signLanguage: { title: "Gebärdensprache", short: "Gebärdensprache", path: "/gebaerdensprache", hint: "Pflichtangabe nach § 4 BITV 2.0: dieselben Inhalte in Deutscher Gebärdensprache, üblicherweise als Video", }, } /** * Zusätzliche Angaben, die keine eigene Seite sind, sondern zu einer gehören. * * Das Gebärdensprach-Video steht bewusst in eigenen Feldern und nicht als * HTML im Markdown: Der Markdown-Prozessor filtert HTML weg, und ihn dafür zu * öffnen würde die Angriffsfläche über alle Textfelder ausweiten. */ export const SITE_MEDIA = ["signLanguageVideo", "signLanguageCaptions"] as const export type SiteMediaKey = (typeof SITE_MEDIA)[number] /** Alle Felder, die API und Admin austauschen. */ export type SitePagesContent = Record /** Leere Ausgangsinhalte – solange im Admin nichts gepflegt wurde. */ export const EMPTY_SITE_PAGES: SitePagesContent = { imprint: "", privacy: "", accessibility: "", easyLanguage: "", signLanguage: "", signLanguageVideo: "", signLanguageCaptions: "", } /** Only the explicitly configured player host may be embedded. */ export const EMBED_HOSTS = ["signlanguage.alangu.de"] as const export function isAllowedEmbedUrl(value: string) { try { const url = new URL(value.trim()) return url.protocol === "https:" && !url.username && !url.password && !url.port && EMBED_HOSTS.some(host => url.hostname === host) } catch { return false } } export function isSafeMediaUrl(value: string) { const input = value.trim() if (!input) return true if (/[\\\u0000-\u0020]/.test(input)) return false try { const base = "https://local.invalid" const url = new URL(input, base) if (url.username || url.password || url.port) return false if (input.startsWith("/") && !input.startsWith("//")) return url.origin === base return isAllowedEmbedUrl(input) } catch { return false } } export function hasSitePageContent(pages: SitePagesContent, key: SitePageKey): boolean { if (pages[key].trim()) return true return key === "signLanguage" && !!pages.signLanguageVideo.trim() && isSafeMediaUrl(pages.signLanguageVideo) } export function validateSitePages(data: unknown, previous: SitePagesContent) { if (!data || typeof data !== "object" || Array.isArray(data)) return ["Ungültige Seiteninhalte."] const input = data as Record const errors: string[] = [] for (const key of [...SITE_PAGES, ...SITE_MEDIA]) { if (typeof input[key] !== "string" || (input[key] as string).length > 100000) { errors.push(key + ": ein Text mit maximal 100.000 Zeichen ist erforderlich.") } } if (errors.length) return errors const next = input as SitePagesContent for (const key of SITE_MEDIA) { if (!isSafeMediaUrl(next[key])) errors.push(key + ": nur lokale Pfade oder der freigegebene alangu-Host sind zulässig.") } for (const key of SITE_PAGES) { if (hasSitePageContent(previous, key) && !hasSitePageContent(next, key)) errors.push(SITE_PAGE_META[key].title + ": eine veröffentlichte Seite darf nicht geleert werden.") if (/\{\{[\s\S]*?\}\}/.test(next[key])) errors.push(SITE_PAGE_META[key].title + ": bitte zuerst die Platzhalter der Vorlage ausfüllen.") } return errors }