diff --git a/.env.example b/.env.example index 27d1b18..a94ca50 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,14 @@ CRON_SECRET=replace-with-a-long-random-secret # links inside emails. Set this to the real domain in production. NEXT_PUBLIC_SERVER_URL=http://localhost:3000 +# Cloudflare Turnstile. The site key is public and is passed to the client +# from the server; the secret is used only by the server-side Siteverify call. +TURNSTILE_SITE_KEY=0x4AAAAAAE7AqgHiQF9DffQO +TURNSTILE_SECRET_KEY= +# Local development value. Production must contain only the public hostname: +# TURNSTILE_HOSTNAMES=anouma.org +TURNSTILE_HOSTNAMES=localhost,127.0.0.1 + # Port the app container publishes on the host (behind a reverse proxy this # usually doesn't need to be reachable directly - see DEPLOYMENT.md). APP_PORT=3000 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 9c42cbc..317fa0e 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -190,6 +190,9 @@ See `.env.example` for the full, commented list. `setup.sh` writes the real file | `DATABASE_URI`, `POSTGRES_*` | Postgres connection | | `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `ANALYTICS_SALT_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards | | `NEXT_PUBLIC_SERVER_URL` | Public URL of the site | +| `TURNSTILE_SITE_KEY` | Public Cloudflare Turnstile site key | +| `TURNSTILE_SECRET_KEY` | Server-only Cloudflare Turnstile secret; keep it in `/opt/anouma/.env` and never in GitHub Actions or the repository | +| `TURNSTILE_HOSTNAMES` | Hostnames accepted by Siteverify; production is `anouma.org` only (local development may use `localhost,127.0.0.1`) | | `SMTP_*` | The existing ANOUMA mail system | | `INBOUND_REPLY_TO`, `INBOUND_EMAIL_SECRET` | Mailbox and shared secret for the inbound email webhook (`/api/email/inbound`) | | `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` | eigener LiveKit-SFU und serverseitige Token-Erzeugung | diff --git a/app/(frontend)/angebote/[slug]/page.tsx b/app/(frontend)/angebote/[slug]/page.tsx index ed707da..af33545 100644 --- a/app/(frontend)/angebote/[slug]/page.tsx +++ b/app/(frontend)/angebote/[slug]/page.tsx @@ -22,6 +22,7 @@ import { resolveRedirect } from "@/lib/seo/redirects"; import { siteConfig } from "@/lib/site"; export const dynamic = "force-dynamic"; +const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? ""; type Args = { params: Promise<{ slug: string }> }; const WEEKDAY_LABELS: Record = { monday: "Montag", tuesday: "Dienstag", wednesday: "Mittwoch", thursday: "Donnerstag", friday: "Freitag", saturday: "Samstag", sunday: "Sonntag" }; @@ -138,7 +139,7 @@ export default async function OfferDetailPage({ params }: Args) { )} {offer.groupCapacity &&

Plätze: {offer.groupCapacity}

} {offer.registrationDeadline &&

Anmeldung möglich bis {new Date(offer.registrationDeadline).toLocaleDateString("de-DE")}

} - + ) : offer.bookable ? ( diff --git a/app/(frontend)/kontakt/page.tsx b/app/(frontend)/kontakt/page.tsx index ff67408..022e240 100644 --- a/app/(frontend)/kontakt/page.tsx +++ b/app/(frontend)/kontakt/page.tsx @@ -14,6 +14,7 @@ import { siteConfig } from "@/lib/site"; export const dynamic = "force-dynamic"; const FALLBACK_DESCRIPTION = "Ich freue mich, von dir zu hören."; +const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? ""; export async function generateMetadata(): Promise { const [contact, seoSettings] = await Promise.all([getContactGlobal(), getSEOSettingsGlobal()]); @@ -60,7 +61,7 @@ export default async function KontaktPage({ searchParams }: { searchParams: Prom
- + diff --git a/app/(frontend)/login/page.tsx b/app/(frontend)/login/page.tsx index a2fae54..b9ddacb 100644 --- a/app/(frontend)/login/page.tsx +++ b/app/(frontend)/login/page.tsx @@ -8,6 +8,7 @@ import { LoginForm } from "@/components/auth/LoginForm"; import { getCurrentCustomer } from "@/lib/auth/customer"; export const dynamic = "force-dynamic"; +const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? ""; export const metadata: Metadata = { title: "Anmelden", @@ -24,7 +25,7 @@ export default async function LoginPage() {
- +

Noch kein Konto?{" "} diff --git a/app/(frontend)/registrieren/page.tsx b/app/(frontend)/registrieren/page.tsx index e92909a..7a1020b 100644 --- a/app/(frontend)/registrieren/page.tsx +++ b/app/(frontend)/registrieren/page.tsx @@ -7,6 +7,7 @@ import { RegisterForm } from "@/components/auth/RegisterForm"; import { getCurrentCustomer } from "@/lib/auth/customer"; export const dynamic = "force-dynamic"; +const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? ""; export const metadata: Metadata = { title: "Konto erstellen", @@ -28,7 +29,7 @@ export default async function RegisterPage() { />

- +

Schon ein Konto?{" "} diff --git a/app/(frontend)/termin-buchen/page.tsx b/app/(frontend)/termin-buchen/page.tsx index 64d7681..914d823 100644 --- a/app/(frontend)/termin-buchen/page.tsx +++ b/app/(frontend)/termin-buchen/page.tsx @@ -17,6 +17,7 @@ import { siteConfig } from "@/lib/site"; export const dynamic = "force-dynamic"; const FALLBACK_DESCRIPTION = "Kennenlernen & Termine vereinbaren."; +const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? ""; export async function generateMetadata(): Promise { const [booking, seoSettings] = await Promise.all([getBookingGlobal(), getSEOSettingsGlobal()]); @@ -122,6 +123,7 @@ export default async function TerminBuchenPage() { />

- +
diff --git a/app/(frontend)/termine/[slug]/page.tsx b/app/(frontend)/termine/[slug]/page.tsx index 6c364a5..88f7933 100644 --- a/app/(frontend)/termine/[slug]/page.tsx +++ b/app/(frontend)/termine/[slug]/page.tsx @@ -25,6 +25,7 @@ import { resolveRedirect } from "@/lib/seo/redirects"; import { siteConfig } from "@/lib/site"; export const dynamic = "force-dynamic"; +const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? ""; type Args = { params: Promise<{ slug: string }> }; @@ -137,7 +138,7 @@ export default async function EventDetailPage({ params }: Args) {

Anmeldung

- +
)} diff --git a/app/(payload)/api/contact/inquiry/route.ts b/app/(payload)/api/contact/inquiry/route.ts index 0e80361..08685f1 100644 --- a/app/(payload)/api/contact/inquiry/route.ts +++ b/app/(payload)/api/contact/inquiry/route.ts @@ -5,6 +5,7 @@ import config from "@payload-config"; import { getClientIp, checkRateLimit } from "@/lib/auth/rateLimit"; import { newConversationAdminEmail } from "@/lib/email/conversationTemplates"; import { sendEmail } from "@/lib/email/sendBookingEmails"; +import { verifyTurnstileToken } from "@/lib/turnstile/server"; export const dynamic = "force-dynamic"; @@ -14,6 +15,7 @@ type Body = { message?: unknown; subject?: unknown; type?: unknown; + token?: unknown; }; function clean(value: unknown, max: number): string { @@ -37,6 +39,9 @@ export async function POST(request: Request) { if (!name || !email || !message || !/^\S+@\S+\.\S+$/.test(email)) { return NextResponse.json({ error: "Bitte fülle alle Pflichtfelder korrekt aus." }, { status: 400 }); } + if (!(await verifyTurnstileToken(body.token, "contact", request))) { + return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 }); + } if (!checkRateLimit(`contact:ip:${getClientIp(request)}`, { max: 8, windowMs: 60 * 60 * 1000 })) { return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 }); } diff --git a/app/(payload)/api/customers/login/route.ts b/app/(payload)/api/customers/login/route.ts new file mode 100644 index 0000000..3dbcbe5 --- /dev/null +++ b/app/(payload)/api/customers/login/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { REST_POST } from "@payloadcms/next/routes"; +import config from "@payload-config"; +import { verifyTurnstileToken } from "@/lib/turnstile/server"; + +export const dynamic = "force-dynamic"; + +const payloadPost = REST_POST(config); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export async function POST(request: Request) { + let body: unknown; + try { + body = await request.clone().json(); + } catch { + return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 }); + } + + const token = isRecord(body) ? body.token : undefined; + if (!(await verifyTurnstileToken(token, "login", request))) { + return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 }); + } + + const sanitizedBody = isRecord(body) ? { ...body } : body; + if (isRecord(sanitizedBody)) delete sanitizedBody.token; + const headers = new Headers(request.headers); + headers.delete("content-length"); + headers.set("content-type", "application/json"); + const downstreamRequest = new Request(request.url, { + method: request.method, + headers, + body: JSON.stringify(sanitizedBody), + }); + return payloadPost(downstreamRequest, { params: Promise.resolve({ slug: ["customers", "login"] }) }); +} diff --git a/app/(payload)/api/customers/route.ts b/app/(payload)/api/customers/route.ts new file mode 100644 index 0000000..8016dd1 --- /dev/null +++ b/app/(payload)/api/customers/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { REST_POST } from "@payloadcms/next/routes"; +import config from "@payload-config"; +import { verifyTurnstileToken } from "@/lib/turnstile/server"; + +export const dynamic = "force-dynamic"; + +const payloadPost = REST_POST(config); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export async function POST(request: Request) { + let body: unknown; + try { + body = await request.clone().json(); + } catch { + return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 }); + } + + const token = isRecord(body) ? body.token : undefined; + if (!(await verifyTurnstileToken(token, "registration", request))) { + return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 }); + } + + const sanitizedBody = isRecord(body) ? { ...body } : body; + if (isRecord(sanitizedBody)) delete sanitizedBody.token; + const headers = new Headers(request.headers); + headers.delete("content-length"); + headers.set("content-type", "application/json"); + const downstreamRequest = new Request(request.url, { + method: request.method, + headers, + body: JSON.stringify(sanitizedBody), + }); + return payloadPost(downstreamRequest, { params: Promise.resolve({ slug: ["customers"] }) }); +} diff --git a/app/(payload)/api/group-registration/route.ts b/app/(payload)/api/group-registration/route.ts index 3d87060..0ec3c3e 100644 --- a/app/(payload)/api/group-registration/route.ts +++ b/app/(payload)/api/group-registration/route.ts @@ -4,6 +4,7 @@ import config from "@payload-config"; import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit"; import { groupRegistrationEmail } from "@/lib/email/conversationTemplates"; import { sendEmail } from "@/lib/email/sendBookingEmails"; +import { verifyTurnstileToken } from "@/lib/turnstile/server"; export const dynamic = "force-dynamic"; @@ -19,6 +20,7 @@ export async function POST(request: Request) { const phone = value(body.phone, 80); const message = value(body.message, 3000); if (!offerSlug || !name || !email || !/^\S+@\S+\.\S+$/.test(email)) return NextResponse.json({ error: "Bitte fülle Name und E-Mail korrekt aus." }, { status: 400 }); + if (!(await verifyTurnstileToken(body.token, "group-registration", request))) return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 }); try { const payload = await getPayload({ config }); diff --git a/app/(payload)/api/meetings/[slug]/join/route.ts b/app/(payload)/api/meetings/[slug]/join/route.ts index 6588a33..da5064a 100644 --- a/app/(payload)/api/meetings/[slug]/join/route.ts +++ b/app/(payload)/api/meetings/[slug]/join/route.ts @@ -7,6 +7,7 @@ import { authorizeMeeting } from "@/lib/meeting/access"; import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit"; import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit"; import { isMeetingParticipantKicked } from "@/lib/meeting/kicks"; +import { verifyTurnstileToken } from "@/lib/turnstile/server"; export const dynamic = "force-dynamic"; @@ -25,7 +26,7 @@ export async function POST(request: Request, { params }: Args) { return NextResponse.json({ error: "Zu viele Beitrittsversuche." }, { status: 429 }); } - let body: { name?: unknown; password?: unknown; clientId?: unknown }; + let body: { name?: unknown; password?: unknown; clientId?: unknown; token?: unknown }; try { body = await request.json(); if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body"); @@ -42,6 +43,9 @@ export async function POST(request: Request, { params }: Args) { if (password.length > 200) { return NextResponse.json({ error: "Das Meeting-Passwort ist ungültig." }, { status: 400 }); } + if (!(await verifyTurnstileToken(body.token, "meeting-join", request))) { + return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 }); + } try { const payload = await getPayload({ config }); diff --git a/app/(payload)/api/meetings/[slug]/register/route.ts b/app/(payload)/api/meetings/[slug]/register/route.ts index 86f5e31..4151472 100644 --- a/app/(payload)/api/meetings/[slug]/register/route.ts +++ b/app/(payload)/api/meetings/[slug]/register/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getPayload } from "payload"; import config from "@payload-config"; import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit"; +import { verifyTurnstileToken } from "@/lib/turnstile/server"; export const dynamic = "force-dynamic"; @@ -35,7 +36,7 @@ export async function POST(request: Request, { params }: Args) { return NextResponse.json({ error: "Zu viele Anmeldungen. Bitte versuche es später erneut." }, { status: 429 }); } - let body: { name?: unknown; email?: unknown }; + let body: { name?: unknown; email?: unknown; token?: unknown }; try { body = await request.json(); if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body"); @@ -48,6 +49,9 @@ export async function POST(request: Request, { params }: Args) { if (!name || name.length > 120 || !email || email.length > 254 || !email.includes("@")) { return NextResponse.json({ error: "Bitte gib deinen Namen und eine gültige E-Mail-Adresse an." }, { status: 400 }); } + if (!(await verifyTurnstileToken(body.token, "meeting-registration", request))) { + return NextResponse.json({ error: "Sicherheitsprüfung fehlgeschlagen. Bitte versuche es erneut." }, { status: 403 }); + } try { const payload = await getPayload({ config }); diff --git a/components/ContactForm.tsx b/components/ContactForm.tsx index 9d40230..689ea4f 100644 --- a/components/ContactForm.tsx +++ b/components/ContactForm.tsx @@ -3,6 +3,7 @@ import { useState, type FormEvent } from "react"; import { track } from "@/lib/analytics/client"; import { TextField, TextAreaField } from "@/components/FormField"; +import { TurnstileWidget } from "@/components/TurnstileWidget"; type ContactFormProps = { toEmail?: string; @@ -12,12 +13,15 @@ type ContactFormProps = { }; export function ContactForm({ + siteKey, subjectPrefix = "Allgemeine Anfrage", type = "contact", submitLabel = "Nachricht senden", -}: ContactFormProps) { +}: ContactFormProps & { siteKey: string }) { const [state, setState] = useState<{ sent: boolean; error?: string }>({ sent: false }); const [submitting, setSubmitting] = useState(false); + const [token, setToken] = useState(null); + const [resetKey, setResetKey] = useState(0); async function handleSubmit(e: FormEvent) { e.preventDefault(); @@ -31,7 +35,7 @@ export function ContactForm({ const response = await fetch("/api/contact/inquiry", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, email, message, subject: `${subjectPrefix} von ${name}`, type }), + body: JSON.stringify({ name, email, message, subject: `${subjectPrefix} von ${name}`, type, token }), }); const result = await response.json().catch(() => ({})); if (!response.ok) { @@ -46,6 +50,7 @@ export function ContactForm({ setState({ sent: false, error: "Verbindung fehlgeschlagen. Bitte versuche es erneut." }); } finally { setSubmitting(false); + setResetKey((key) => key + 1); } } @@ -54,8 +59,10 @@ export function ContactForm({ + + ); } diff --git a/components/TurnstileWidget.tsx b/components/TurnstileWidget.tsx new file mode 100644 index 0000000..c2cd12f --- /dev/null +++ b/components/TurnstileWidget.tsx @@ -0,0 +1,103 @@ +"use client"; + +import Script from "next/script"; +import { useCallback, useEffect, useRef, useState } from "react"; + +type TurnstileWidgetId = string; +type TurnstileApi = { + render: ( + container: HTMLElement, + options: { + sitekey: string; + action: string; + callback: (token: string) => void; + "expired-callback": () => void; + "error-callback": () => void; + }, + ) => TurnstileWidgetId; + reset: (widgetId: TurnstileWidgetId) => void; +}; + +declare global { + interface Window { + turnstile?: TurnstileApi; + } +} + +type TurnstileWidgetProps = { + siteKey: string; + action: string; + resetKey: number; + onTokenChange: (token: string | null) => void; +}; + +export function TurnstileWidget({ siteKey, action, resetKey, onTokenChange }: TurnstileWidgetProps) { + const containerRef = useRef(null); + const widgetIdRef = useRef(null); + const onTokenChangeRef = useRef(onTokenChange); + const [loadError, setLoadError] = useState(false); + const [verificationStatus, setVerificationStatus] = useState<"idle" | "ready" | "expired">("idle"); + + useEffect(() => { + onTokenChangeRef.current = onTokenChange; + }, [onTokenChange]); + + useEffect(() => { + if (resetKey === 0) return; + if (widgetIdRef.current !== null && window.turnstile) { + window.turnstile.reset(widgetIdRef.current); + } + onTokenChangeRef.current(null); + }, [resetKey]); + + const render = useCallback(() => { + if (!siteKey || !containerRef.current || widgetIdRef.current !== null) return; + if (!window.turnstile) { + setLoadError(true); + return; + } + + try { + widgetIdRef.current = window.turnstile.render(containerRef.current, { + sitekey: siteKey, + action, + callback: (token) => { + setVerificationStatus("ready"); + onTokenChangeRef.current(token); + }, + "expired-callback": () => { + setVerificationStatus("expired"); + onTokenChangeRef.current(null); + }, + "error-callback": () => { + onTokenChangeRef.current(null); + setLoadError(true); + }, + }); + } catch { + setLoadError(true); + onTokenChangeRef.current(null); + } + }, [action, siteKey]); + + return ( +
+