Gebaerdensprach-Video ermoeglichen und Erklaerung anpassen

Ein DGS-Video liess sich bisher gar nicht einbinden: Der Markdown-
Prozessor laeuft mit Sanitizing, und remark reicht rohes HTML dann von
vornherein nicht durch - <video> kam als leeres <p> heraus. Eine
erweiterte Freigabeliste allein half deshalb nicht.

Statt rohes HTML fuer alle Textfelder zu oeffnen - auch fuer die 121
Erklaerungen - bekommt die Gebaerdensprach-Seite zwei eigene Felder fuer
Video- und Untertiteladresse. Ausgegeben wird ein natives <video
controls> mit <track kind="captions"> und Bildunterschrift.

Adressen werden zweimal geprueft, beim Speichern und vor der Ausgabe:
zugelassen sind nur der eigene Server und https. javascript:, data:,
protokollrelative und einfache http-Adressen werden abgewiesen. iframe
bleibt bewusst gesperrt - eine YouTube-Einbindung wuerde Besucherdaten
an Dritte uebertragen.

Erklaerung zur Barrierefreiheit entsprechend gekuerzt: Die beiden
Luecken nach § 4 BITV 2.0 entfallen, es bleibt die ungepruefte
Druckansicht.

Geprueft: Video wird mit Untertitelspur ausgeliefert und ist per Tastatur
erreichbar, axe-core meldet auf beiden neuen Seiten in hell und dunkel
keine Verstoesse, Adresspruefung weist alle acht Testfaelle korrekt zu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Weapie 2026-09-08 16:18:09 +02:00
parent 3633e8a4cf
commit 5aa8880190
7 changed files with 166 additions and 14 deletions

View File

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import { requireAdmin } from '@/lib/require-admin' 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' import { readSitePages } from '@/lib/site-pages.server'
// Prisma client instance // Prisma client instance
@ -31,9 +31,28 @@ export async function PUT(request: NextRequest) {
// Nur die bekannten Seiten übernehmen, jeweils getrimmt. Ein leerer Text // Nur die bekannten Seiten übernehmen, jeweils getrimmt. Ein leerer Text
// ist zulässig und bedeutet: Seite nicht veröffentlichen. // ist zulässig und bedeutet: Seite nicht veröffentlichen.
const values = Object.fromEntries( 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({ const saved = await prisma.sitePages.upsert({
where: { id: 1 }, where: { id: 1 },
update: values, update: values,

View File

@ -3,7 +3,7 @@ import { notFound } from "next/navigation"
import { PublicHeader } from "@/components/public-header" import { PublicHeader } from "@/components/public-header"
import { SiteFooter } from "@/components/site-footer" import { SiteFooter } from "@/components/site-footer"
import { markdownToHtml } from "@/lib/markdown" 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" import { readSitePages } from "@/lib/site-pages.server"
/** /**
@ -23,6 +23,15 @@ export async function LegalPage({ page }: { page: SitePageKey }) {
const html = await markdownToHtml(content) const html = await markdownToHtml(content)
const meta = SITE_PAGE_META[page] 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 ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="flex min-h-screen flex-col bg-background">
<PublicHeader subtitle={meta.title} /> <PublicHeader subtitle={meta.title} />
@ -31,6 +40,36 @@ export async function LegalPage({ page }: { page: SitePageKey }) {
<h1 className="mb-6 text-2xl font-semibold tracking-tight"> <h1 className="mb-6 text-2xl font-semibold tracking-tight">
{meta.title} {meta.title}
</h1> </h1>
{/* 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 && (
<figure className="mb-8">
<video
controls
preload="metadata"
className="w-full rounded-lg border bg-muted"
>
<source src={video} />
{captions && (
<track
src={captions}
kind="captions"
srcLang="de"
label="Deutsch"
default
/>
)}
Ihr Browser kann dieses Video nicht abspielen.{" "}
<a href={video}>Video herunterladen</a>
</video>
<figcaption className="mt-2 text-sm text-muted-foreground">
Erläuterungen zu diesem Portal in Deutscher Gebärdensprache
</figcaption>
</figure>
)}
<div <div
className="markdown-content" className="markdown-content"
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}

View File

@ -15,6 +15,8 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { MarkdownInput } from "@/components/markdown-input" import { MarkdownInput } from "@/components/markdown-input"
import { updateSitePages, useSitePages } from "@/hooks/use-site-pages" import { updateSitePages, useSitePages } from "@/hooks/use-site-pages"
@ -137,16 +139,13 @@ gegengelesen werden. Das ist bei Leichter Sprache üblich.*`,
signLanguage: `## Informationen in Deutscher Gebärdensprache signLanguage: `## Informationen in Deutscher Gebärdensprache
Hier entsteht ein Video in Deutscher Gebärdensprache. Das Video oben erklärt:
- die wesentlichen Inhalte dieses Portals
Das Video erklärt:
- die wesentlichen Inhalte dieser Website
- wie die Navigation funktioniert - wie die Navigation funktioniert
- die wesentlichen Inhalte der Erklärung zur Barrierefreiheit - die wesentlichen Inhalte der Erklärung zur Barrierefreiheit
*[Video einbinden, sobald es vorliegt. Erforderlich nach § 4 BITV 2.0. Die Adresse des Videos tragen Sie oben in das Feld ein; es erscheint dann
Ein Video braucht eine Produktion mit gehörlosen Dolmetscherinnen oder über diesem Text.
Dolmetschern – Untertitel allein genügen nicht.]*
### Kontakt ### Kontakt
@ -257,6 +256,55 @@ export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
)} )}
</div> </div>
{/* Das Video steht in eigenen Feldern, nicht im Markdown:
Der Markdown-Prozessor filtert HTML weg. */}
{key === "signLanguage" && (
<div className="grid gap-3 rounded-lg border bg-muted/30 p-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="signLanguageVideo" className="gap-1">
Adresse des Videos
</Label>
<Input
id="signLanguageVideo"
value={formData.signLanguageVideo}
onChange={(e) =>
setFormData((prev) => ({
...prev,
signLanguageVideo: e.target.value,
}))
}
placeholder="/videos/gebaerdensprache.mp4"
/>
<p className="text-xs text-muted-foreground">
Eigener Server (beginnt mit /) oder https. Kein
YouTube oder Vimeo – das überträgt Besucherdaten an
Dritte.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="signLanguageCaptions" className="gap-1">
Untertiteldatei (optional)
</Label>
<Input
id="signLanguageCaptions"
value={formData.signLanguageCaptions}
onChange={(e) =>
setFormData((prev) => ({
...prev,
signLanguageCaptions: e.target.value,
}))
}
placeholder="/videos/gebaerdensprache.vtt"
/>
<p className="text-xs text-muted-foreground">
WebVTT-Datei. Nötig, sobald das Video gesprochenen Ton
enthält (WCAG 1.2.2).
</p>
</div>
</div>
)}
<MarkdownInput <MarkdownInput
id={`site-page-${key}`} id={`site-page-${key}`}
label={SITE_PAGE_META[key].title} label={SITE_PAGE_META[key].title}

View File

@ -1,6 +1,11 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import { EMPTY_SITE_PAGES, SITE_PAGES, SitePagesContent } from '@/lib/site-pages' import {
EMPTY_SITE_PAGES,
SITE_MEDIA,
SITE_PAGES,
SitePagesContent,
} from '@/lib/site-pages'
// Prisma client instance // Prisma client instance
const prisma = new PrismaClient() const prisma = new PrismaClient()
@ -15,6 +20,6 @@ export async function readSitePages(): Promise<SitePagesContent> {
if (!row) return { ...EMPTY_SITE_PAGES } if (!row) return { ...EMPTY_SITE_PAGES }
return Object.fromEntries( return Object.fromEntries(
SITE_PAGES.map((key) => [key, row[key] ?? '']) [...SITE_PAGES, ...SITE_MEDIA].map((key) => [key, row[key] ?? ''])
) as SitePagesContent ) as SitePagesContent
} }

View File

@ -53,8 +53,19 @@ export const SITE_PAGE_META: Record<
}, },
} }
/** Inhalte der Seiten, wie sie API und Admin austauschen. */ /**
export type SitePagesContent = Record<SitePageKey, string> * 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. */ /** Leere Ausgangsinhalte – solange im Admin nichts gepflegt wurde. */
export const EMPTY_SITE_PAGES: SitePagesContent = { export const EMPTY_SITE_PAGES: SitePagesContent = {
@ -63,4 +74,23 @@ export const EMPTY_SITE_PAGES: SitePagesContent = {
accessibility: "", accessibility: "",
easyLanguage: "", easyLanguage: "",
signLanguage: "", 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
}
} }

View File

@ -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 '';

View File

@ -43,6 +43,8 @@ model SitePages {
accessibility String @default("") // Erklärung zur Barrierefreiheit (§ 7 BITV 2.0) accessibility String @default("") // Erklärung zur Barrierefreiheit (§ 7 BITV 2.0)
easyLanguage String @default("") // Erläuterungen in Leichter Sprache (§ 4 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) 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 updatedAt DateTime @updatedAt // Automatisch bei Update gesetzt
} }