Compare commits

...

2 Commits

Author SHA1 Message Date
Weapie
0134dd6db4 alangu.de fuer die Gebaerdensprach-Einbindung freigeben
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m33s
Der Player wird als iframe eingebunden, aber nicht als rohes HTML aus dem
Textfeld: Die Anwendung setzt die Attribute, im Admin steht nur die
Adresse. Damit laesst sich ueber das Feld nichts anderes einschleusen.

Freigegeben ist alangu.de samt Unterdomains, sonst nichts. Die Pruefung
haengt am Hostnamen, nicht an einem Textvergleich - getestet mit
boese-alangu.de.angreifer.example und evil.example/?a=alangu.de, beide
werden abgewiesen, ebenso http statt https.

Vom uebergebenen Einbettungscode weggelassen: accelerometer und
gyroscope. Ein Videoplayer braucht keine Bewegungssensoren.

Ausserdem: Die Gebaerdensprach-Seite erscheint jetzt auch ohne Text,
wenn eine Videoadresse hinterlegt ist - dort ist das Video der Inhalt.
Fusszeile und Seite entscheiden das ueber dieselbe Funktion, sonst gaebe
es eine erreichbare Seite ohne Link.

Geprueft am echten Player: laedt mit HTTP 200 samt CSS, JS und hls.js,
axe-core meldet in hell und dunkel keine Verstoesse, der Rahmen ist per
Tastatur erreichbar, und die Fusszeile verlinkt die Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:28:41 +02:00
Weapie
5aa8880190 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>
2026-09-08 16:18:09 +02:00
8 changed files with 246 additions and 20 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,13 @@ 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,
hasSitePageContent,
isAllowedEmbedUrl,
isSafeMediaUrl,
} from "@/lib/site-pages"
import { readSitePages } from "@/lib/site-pages.server" import { readSitePages } from "@/lib/site-pages.server"
/** /**
@ -17,12 +23,24 @@ import { readSitePages } from "@/lib/site-pages.server"
export async function LegalPage({ page }: { page: SitePageKey }) { export async function LegalPage({ page }: { page: SitePageKey }) {
const pages = await readSitePages() const pages = await readSitePages()
const content = pages[page].trim() const content = pages[page].trim()
if (!content) notFound()
const html = await markdownToHtml(content)
const meta = SITE_PAGE_META[page] const meta = SITE_PAGE_META[page]
// Auf der Gebärdensprach-Seite ist das Video der eigentliche Inhalt. Sie
// erscheint deshalb auch dann, wenn nur eine Videoadresse hinterlegt ist –
// ein zusätzlicher Text ist willkommen, aber keine Bedingung.
const video =
page === "signLanguage" && isSafeMediaUrl(pages.signLanguageVideo)
? pages.signLanguageVideo.trim()
: ""
if (!hasSitePageContent(pages, page)) notFound()
const html = content ? await markdownToHtml(content) : ""
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 +49,51 @@ 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">
{isAllowedEmbedUrl(video) ? (
// Player eines zugelassenen Anbieters. Die Attribute setzt die
// Anwendung, nicht der Text im Admin – so lässt sich über das
// Adressfeld nichts anderes einschleusen.
<div className="aspect-video w-full overflow-hidden rounded-lg border bg-muted">
<iframe
src={video}
title="Video in Deutscher Gebärdensprache mit Erläuterungen zu diesem Portal"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
allowFullScreen
className="size-full border-0"
/>
</div>
) : (
<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

@ -1,7 +1,7 @@
import Link from "next/link" import Link from "next/link"
import { appName } from "@/lib/app-config" import { appName } from "@/lib/app-config"
import { SITE_PAGES, SITE_PAGE_META } from "@/lib/site-pages" import { SITE_PAGES, SITE_PAGE_META, hasSitePageContent } from "@/lib/site-pages"
import { readSitePages } from "@/lib/site-pages.server" import { readSitePages } from "@/lib/site-pages.server"
/** /**
@ -14,7 +14,7 @@ import { readSitePages } from "@/lib/site-pages.server"
*/ */
export async function SiteFooter() { export async function SiteFooter() {
const pages = await readSitePages() const pages = await readSitePages()
const links = SITE_PAGES.filter((key) => pages[key].trim()) const links = SITE_PAGES.filter((key) => hasSitePageContent(pages, key))
return ( return (
<footer className="mt-12 border-t"> <footer className="mt-12 border-t">

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,58 @@ 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">
Videodatei auf dem eigenen Server (beginnt mit /) oder
Player-Adresse eines freigegebenen Anbieters. Derzeit
freigegeben: alangu.de. Andere Videoportale sind
bewusst gesperrt – sie übertragen 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, nur bei einer eigenen Videodatei nötig
(WCAG 1.2.2). Ein Player-Anbieter bringt seine
Untertitel selbst mit.
</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,70 @@ export const EMPTY_SITE_PAGES: SitePagesContent = {
accessibility: "", accessibility: "",
easyLanguage: "", easyLanguage: "",
signLanguage: "", signLanguage: "",
signLanguageVideo: "",
signLanguageCaptions: "",
}
/**
* Anbieter, deren Player als eingebetteter Rahmen zugelassen ist.
*
* Bewusst eine kurze Liste statt einer allgemeinen Freigabe für iframes: Ein
* eingebetteter Rahmen lädt beim Seitenaufruf von einem fremden Server und
* überträgt dabei IP-Adresse und Browserkennung der Besucherinnen und
* Besucher. Das ist bei einem Gebärdensprach-Dienst vertretbar, für den ein
* Auftragsverarbeitungsvertrag besteht – bei einem Videoportal wie YouTube
* wäre es das nicht.
*
* Eingetragen wird die Domain; Unterdomains sind eingeschlossen.
*/
export const EMBED_HOSTS = ["alangu.de"] as const
/**
* Hat eine Seite Inhalt und wird deshalb ausgeliefert und verlinkt?
*
* Auf der Gebärdensprach-Seite ist das Video der eigentliche Inhalt; sie zählt
* daher auch ohne Text als gepflegt. Fußzeile und Seite selbst müssen dieselbe
* Regel verwenden, sonst gibt es eine erreichbare Seite ohne Link oder einen
* Link ins Leere.
*/
export function hasSitePageContent(
pages: SitePagesContent,
key: SitePageKey
): boolean {
if (pages[key].trim()) return true
return key === "signLanguage" && pages.signLanguageVideo.trim().length > 0
}
/** Gehört die Adresse zu einem zugelassenen Einbettungs-Anbieter? */
export function isAllowedEmbedUrl(value: string) {
const wert = value.trim()
if (!wert) return false
try {
const url = new URL(wert)
if (url.protocol !== "https:") return false
return EMBED_HOSTS.some(
(host) => url.hostname === host || url.hostname.endsWith(`.${host}`)
)
} catch {
return false
}
}
/**
* Prüft eine Medienadresse. Zugelassen sind 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
} }