diff --git a/app/api/site-pages/route.ts b/app/api/site-pages/route.ts index 7a9eee9..5bc8207 100644 --- a/app/api/site-pages/route.ts +++ b/app/api/site-pages/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { PrismaClient } from '@prisma/client' import { requireAdmin } from '@/lib/require-admin' -import { SITE_PAGES } from '@/lib/site-pages' +import { SITE_MEDIA, SITE_PAGES, isSafeMediaUrl } from '@/lib/site-pages' import { readSitePages } from '@/lib/site-pages.server' // Prisma client instance @@ -31,9 +31,28 @@ export async function PUT(request: NextRequest) { // Nur die bekannten Seiten übernehmen, jeweils getrimmt. Ein leerer Text // ist zulässig und bedeutet: Seite nicht veröffentlichen. const values = Object.fromEntries( - SITE_PAGES.map((key) => [key, String(data[key] ?? '').trim()]) + [...SITE_PAGES, ...SITE_MEDIA].map((key) => [ + key, + String(data[key] ?? '').trim(), + ]) ) + // Medienadressen landen in einem src-Attribut. Nur eigener Server oder + // https – alles andere wird abgewiesen, statt still gespeichert zu werden. + const ungueltig = SITE_MEDIA.filter((key) => !isSafeMediaUrl(values[key])) + if (ungueltig.length > 0) { + return NextResponse.json( + { + error: 'Ungültige Adresse', + details: ungueltig.map( + (key) => + `${key}: nur eine Adresse auf diesem Server (beginnt mit /) oder https ist zulässig` + ), + }, + { status: 400 } + ) + } + const saved = await prisma.sitePages.upsert({ where: { id: 1 }, update: values, diff --git a/components/legal-page.tsx b/components/legal-page.tsx index 2c5db05..db31c5f 100644 --- a/components/legal-page.tsx +++ b/components/legal-page.tsx @@ -3,7 +3,7 @@ import { notFound } from "next/navigation" import { PublicHeader } from "@/components/public-header" import { SiteFooter } from "@/components/site-footer" import { markdownToHtml } from "@/lib/markdown" -import { SITE_PAGE_META, SitePageKey } from "@/lib/site-pages" +import { SITE_PAGE_META, SitePageKey, isSafeMediaUrl } from "@/lib/site-pages" import { readSitePages } from "@/lib/site-pages.server" /** @@ -23,6 +23,15 @@ export async function LegalPage({ page }: { page: SitePageKey }) { const html = await markdownToHtml(content) const meta = SITE_PAGE_META[page] + // Zweite Prüfung vor der Ausgabe: Was in der Datenbank steht, könnte aus + // einer Zeit vor der Prüfung in der API stammen. + const video = isSafeMediaUrl(pages.signLanguageVideo) + ? pages.signLanguageVideo.trim() + : "" + const captions = isSafeMediaUrl(pages.signLanguageCaptions) + ? pages.signLanguageCaptions.trim() + : "" + return (
@@ -31,6 +40,36 @@ export async function LegalPage({ page }: { page: SitePageKey }) {

{meta.title}

+ + {/* Erläuterungen in Deutscher Gebärdensprache nach § 4 BITV 2.0. + Der native Player ist mit der Tastatur bedienbar; Untertitel + werden eingebunden, sobald eine WebVTT-Datei hinterlegt ist. */} + {page === "signLanguage" && video && ( +
+ +
+ Erläuterungen zu diesem Portal in Deutscher Gebärdensprache +
+
+ )} +
+ {/* Das Video steht in eigenen Feldern, nicht im Markdown: + Der Markdown-Prozessor filtert HTML weg. */} + {key === "signLanguage" && ( +
+
+ + + setFormData((prev) => ({ + ...prev, + signLanguageVideo: e.target.value, + })) + } + placeholder="/videos/gebaerdensprache.mp4" + /> +

+ Eigener Server (beginnt mit /) oder https. Kein + YouTube oder Vimeo – das überträgt Besucherdaten an + Dritte. +

+
+ +
+ + + setFormData((prev) => ({ + ...prev, + signLanguageCaptions: e.target.value, + })) + } + placeholder="/videos/gebaerdensprache.vtt" + /> +

+ WebVTT-Datei. Nötig, sobald das Video gesprochenen Ton + enthält (WCAG 1.2.2). +

+
+
+ )} + { if (!row) return { ...EMPTY_SITE_PAGES } return Object.fromEntries( - SITE_PAGES.map((key) => [key, row[key] ?? '']) + [...SITE_PAGES, ...SITE_MEDIA].map((key) => [key, row[key] ?? '']) ) as SitePagesContent } diff --git a/lib/site-pages.ts b/lib/site-pages.ts index dbfe539..96e87bd 100644 --- a/lib/site-pages.ts +++ b/lib/site-pages.ts @@ -53,8 +53,19 @@ export const SITE_PAGE_META: Record< }, } -/** Inhalte der Seiten, wie sie API und Admin austauschen. */ -export type SitePagesContent = Record +/** + * 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 = { @@ -63,4 +74,23 @@ export const EMPTY_SITE_PAGES: SitePagesContent = { accessibility: "", easyLanguage: "", signLanguage: "", + signLanguageVideo: "", + signLanguageCaptions: "", +} + +/** + * Prüft eine Medienadresse. Zugelassen sind nur eine Adresse auf dem eigenen + * Server (mit / beginnend) und https. Damit sind `javascript:` und `data:` + * ausgeschlossen, bevor der Wert in ein src-Attribut gelangt. + */ +export function isSafeMediaUrl(value: string) { + const wert = value.trim() + if (!wert) return true + if (wert.startsWith("/") && !wert.startsWith("//")) return true + + try { + return new URL(wert).protocol === "https:" + } catch { + return false + } } diff --git a/prisma/migrations/20260908160000_gebaerdensprache_video/migration.sql b/prisma/migrations/20260908160000_gebaerdensprache_video/migration.sql new file mode 100644 index 0000000..b0899aa --- /dev/null +++ b/prisma/migrations/20260908160000_gebaerdensprache_video/migration.sql @@ -0,0 +1,9 @@ +-- Video für die Erläuterungen in Deutscher Gebärdensprache (§ 4 BITV 2.0). +-- +-- Bewusst als eigene Felder statt als rohes HTML im Markdown: Der Markdown- +-- Prozessor filtert HTML aus gutem Grund weg, und ihn für alle Textfelder zu +-- öffnen, würde die Angriffsfläche über sämtliche Erklärungen ausweiten. +-- +-- Leer bedeutet: kein Video, es wird nur der Text der Seite ausgeliefert. +ALTER TABLE "SitePages" ADD COLUMN "signLanguageVideo" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "SitePages" ADD COLUMN "signLanguageCaptions" TEXT NOT NULL DEFAULT ''; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6c26405..c31acf8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -43,6 +43,8 @@ model SitePages { accessibility String @default("") // Erklärung zur Barrierefreiheit (§ 7 BITV 2.0) easyLanguage String @default("") // Erläuterungen in Leichter Sprache (§ 4 BITV 2.0) signLanguage String @default("") // Erläuterungen in Deutscher Gebärdensprache (§ 4 BITV 2.0) + signLanguageVideo String @default("") // Adresse des Gebärdensprach-Videos + signLanguageCaptions String @default("") // Adresse der Untertiteldatei (WebVTT), optional updatedAt DateTime @updatedAt // Automatisch bei Update gesetzt }