Compare commits
2 Commits
3633e8a4cf
...
0134dd6db4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0134dd6db4 | ||
|
|
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,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 }}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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,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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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