Compare commits

..

No commits in common. "0134dd6db4282b6c18199ef64830338bb0038cf7" and "3633e8a4cf98f8d8e02f5d73b610d2106c65088e" have entirely different histories.

8 changed files with 20 additions and 246 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_MEDIA, SITE_PAGES, isSafeMediaUrl } from '@/lib/site-pages' import { SITE_PAGES } 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,28 +31,9 @@ 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, ...SITE_MEDIA].map((key) => [ SITE_PAGES.map((key) => [key, String(data[key] ?? '').trim()])
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,13 +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 { import { SITE_PAGE_META, SitePageKey } from "@/lib/site-pages"
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"
/** /**
@ -23,24 +17,12 @@ 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} />
@ -49,51 +31,6 @@ 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, hasSitePageContent } from "@/lib/site-pages" import { SITE_PAGES, SITE_PAGE_META } 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) => hasSitePageContent(pages, key)) const links = SITE_PAGES.filter((key) => pages[key].trim())
return ( return (
<footer className="mt-12 border-t"> <footer className="mt-12 border-t">

View File

@ -15,8 +15,6 @@ 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"
@ -139,13 +137,16 @@ gegengelesen werden. Das ist bei Leichter Sprache üblich.*`,
signLanguage: `## Informationen in Deutscher Gebärdensprache signLanguage: `## Informationen in Deutscher Gebärdensprache
Das Video oben erklärt: Hier entsteht ein Video in Deutscher Gebärdensprache.
- 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
Die Adresse des Videos tragen Sie oben in das Feld ein; es erscheint dann *[Video einbinden, sobald es vorliegt. Erforderlich nach § 4 BITV 2.0.
über diesem Text. Ein Video braucht eine Produktion mit gehörlosen Dolmetscherinnen oder
Dolmetschern – Untertitel allein genügen nicht.]*
### Kontakt ### Kontakt
@ -256,58 +257,6 @@ 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,11 +1,6 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import { import { EMPTY_SITE_PAGES, SITE_PAGES, SitePagesContent } from '@/lib/site-pages'
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()
@ -20,6 +15,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, ...SITE_MEDIA].map((key) => [key, row[key] ?? '']) SITE_PAGES.map((key) => [key, row[key] ?? ''])
) as SitePagesContent ) as SitePagesContent
} }

View File

@ -53,19 +53,8 @@ export const SITE_PAGE_META: Record<
}, },
} }
/** /** Inhalte der Seiten, wie sie API und Admin austauschen. */
* Zusätzliche Angaben, die keine eigene Seite sind, sondern zu einer gehören. export type SitePagesContent = Record<SitePageKey, string>
*
* 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 = {
@ -74,70 +63,4 @@ 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

@ -1,9 +0,0 @@
-- 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,8 +43,6 @@ 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
} }