Compare commits
No commits in common. "0134dd6db4282b6c18199ef64830338bb0038cf7" and "3633e8a4cf98f8d8e02f5d73b610d2106c65088e" have entirely different histories.
0134dd6db4
...
3633e8a4cf
@ -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,
|
||||||
|
|||||||
@ -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 }}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 '';
|
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user