syncova-policies/lib/site-pages.ts
Weapie 265ca8ac38
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 5m15s
Improve accessibility and access controls; verify production and database recovery
2026-09-14 11:44:41 +02:00

133 lines
4.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<SitePageKey | SiteMediaKey, string>
/** 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<string, unknown>
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
}