Add Turnstile protection to user actions
This commit is contained in:
@@ -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<string, string> = { 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 && <p className="mb-2 text-sm text-anouma-plum/75">Plätze: {offer.groupCapacity}</p>}
|
||||
{offer.registrationDeadline && <p className="mb-6 text-sm text-anouma-plum/75">Anmeldung möglich bis {new Date(offer.registrationDeadline).toLocaleDateString("de-DE")}</p>}
|
||||
<GroupRegistrationForm offerSlug={offer.slug!} />
|
||||
<GroupRegistrationForm offerSlug={offer.slug!} siteKey={turnstileSiteKey} />
|
||||
</div>
|
||||
</Section>
|
||||
) : offer.bookable ? (
|
||||
|
||||
@@ -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<Metadata> {
|
||||
const [contact, seoSettings] = await Promise.all([getContactGlobal(), getSEOSettingsGlobal()]);
|
||||
@@ -60,7 +61,7 @@ export default async function KontaktPage({ searchParams }: { searchParams: Prom
|
||||
<Section tone="plain">
|
||||
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
|
||||
<Reveal>
|
||||
<ContactForm subjectPrefix={isAppointment ? "Terminanfrage" : "Allgemeine Anfrage"} type={isAppointment ? "appointment" : "contact"} submitLabel={isAppointment ? "Terminanfrage senden" : "Nachricht senden"} />
|
||||
<ContactForm siteKey={turnstileSiteKey} subjectPrefix={isAppointment ? "Terminanfrage" : "Allgemeine Anfrage"} type={isAppointment ? "appointment" : "contact"} submitLabel={isAppointment ? "Terminanfrage senden" : "Nachricht senden"} />
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1} className="space-y-8">
|
||||
|
||||
@@ -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() {
|
||||
<Section tone="plain">
|
||||
<div className="mx-auto max-w-md">
|
||||
<Suspense>
|
||||
<LoginForm />
|
||||
<LoginForm siteKey={turnstileSiteKey} />
|
||||
</Suspense>
|
||||
<p className="mt-6 text-center text-sm text-anouma-plum">
|
||||
Noch kein Konto?{" "}
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
<Section tone="plain">
|
||||
<div className="mx-auto max-w-md">
|
||||
<RegisterForm />
|
||||
<RegisterForm siteKey={turnstileSiteKey} />
|
||||
<p className="mt-6 text-center text-sm text-anouma-plum">
|
||||
Schon ein Konto?{" "}
|
||||
<Link href="/login" className="font-medium text-anouma-mauve-dark underline underline-offset-4">
|
||||
|
||||
@@ -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<Metadata> {
|
||||
const [booking, seoSettings] = await Promise.all([getBookingGlobal(), getSEOSettingsGlobal()]);
|
||||
@@ -122,6 +123,7 @@ export default async function TerminBuchenPage() {
|
||||
/>
|
||||
<div className="mt-10">
|
||||
<ContactForm
|
||||
siteKey={turnstileSiteKey}
|
||||
subjectPrefix="Terminanfrage über anouma.org"
|
||||
type="appointment"
|
||||
submitLabel="Terminanfrage senden"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { combineDateAndTime } from "@/lib/meeting/status";
|
||||
import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Meeting beitreten",
|
||||
@@ -42,7 +43,7 @@ export default async function BeitretenPage({ params }: Args) {
|
||||
</p>
|
||||
|
||||
<div className="mt-10 rounded-[2rem] bg-white/70 p-8 text-left shadow-sm backdrop-blur">
|
||||
<JoinForm slug={slug} />
|
||||
<JoinForm slug={slug} siteKey={turnstileSiteKey} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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) {
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Anmeldung</h2>
|
||||
<div className="mt-4 max-w-sm">
|
||||
<RegisterForm slug={slug} />
|
||||
<RegisterForm slug={slug} siteKey={turnstileSiteKey} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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"] }) });
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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"] }) });
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user