All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 5m15s
133 lines
4.6 KiB
TypeScript
133 lines
4.6 KiB
TypeScript
/**
|
||
* 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
|
||
}
|