Compare commits
2 Commits
3633e8a4cf
...
0134dd6db4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0134dd6db4 | ||
|
|
5aa8880190 |
@ -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,
|
||||
|
||||
@ -3,7 +3,13 @@ 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,
|
||||
hasSitePageContent,
|
||||
isAllowedEmbedUrl,
|
||||
isSafeMediaUrl,
|
||||
} from "@/lib/site-pages"
|
||||
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 }) {
|
||||
const pages = await readSitePages()
|
||||
const content = pages[page].trim()
|
||||
|
||||
if (!content) notFound()
|
||||
|
||||
const html = await markdownToHtml(content)
|
||||
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 (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<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">
|
||||
{meta.title}
|
||||
</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
|
||||
className="markdown-content"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import Link from "next/link"
|
||||
|
||||
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"
|
||||
|
||||
/**
|
||||
@ -14,7 +14,7 @@ import { readSitePages } from "@/lib/site-pages.server"
|
||||
*/
|
||||
export async function SiteFooter() {
|
||||
const pages = await readSitePages()
|
||||
const links = SITE_PAGES.filter((key) => pages[key].trim())
|
||||
const links = SITE_PAGES.filter((key) => hasSitePageContent(pages, key))
|
||||
|
||||
return (
|
||||
<footer className="mt-12 border-t">
|
||||
|
||||
@ -15,6 +15,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 { MarkdownInput } from "@/components/markdown-input"
|
||||
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
|
||||
|
||||
Hier entsteht ein Video in Deutscher Gebärdensprache.
|
||||
|
||||
Das Video erklärt:
|
||||
- die wesentlichen Inhalte dieser Website
|
||||
Das Video oben erklärt:
|
||||
- die wesentlichen Inhalte dieses Portals
|
||||
- wie die Navigation funktioniert
|
||||
- die wesentlichen Inhalte der Erklärung zur Barrierefreiheit
|
||||
|
||||
*[Video einbinden, sobald es vorliegt. Erforderlich nach § 4 BITV 2.0.
|
||||
Ein Video braucht eine Produktion mit gehörlosen Dolmetscherinnen oder
|
||||
Dolmetschern – Untertitel allein genügen nicht.]*
|
||||
Die Adresse des Videos tragen Sie oben in das Feld ein; es erscheint dann
|
||||
über diesem Text.
|
||||
|
||||
### Kontakt
|
||||
|
||||
@ -257,6 +256,58 @@ export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
|
||||
)}
|
||||
</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
|
||||
id={`site-page-${key}`}
|
||||
label={SITE_PAGE_META[key].title}
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
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
|
||||
const prisma = new PrismaClient()
|
||||
@ -15,6 +20,6 @@ export async function readSitePages(): Promise<SitePagesContent> {
|
||||
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
|
||||
}
|
||||
|
||||
@ -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. */
|
||||
export const EMPTY_SITE_PAGES: SitePagesContent = {
|
||||
@ -63,4 +74,70 @@ export const EMPTY_SITE_PAGES: SitePagesContent = {
|
||||
accessibility: "",
|
||||
easyLanguage: "",
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user