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:
parent
3633e8a4cf
commit
5aa8880190
@ -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,
|
||||||
|
|||||||
@ -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 }}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 '';
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user