Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 19 additions & 17 deletions apps/web/src/app/api/forms/[slug]/submit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { getCurrentUser } from "@/lib/auth";
import { captchaEnabled, verifyCaptcha } from "@/lib/captcha";
import { isPrimaryHostname, requestHostname } from "@/lib/custom-domain";
import { recordExperimentConversion } from "@/lib/experiment";
import { getGuildCaptchaSecret } from "@/lib/guild-captcha";
import { parseFormSpec, resolveStatus } from "@/lib/forms";
import { getGuildPlan } from "@/lib/plan";
import { clientIp, rateLimit } from "@/lib/rate-limit";
Expand Down Expand Up @@ -80,23 +81,6 @@ export async function POST(
return NextResponse.json({ error: "Missing answers." }, { status: 400 });
}

// Captcha (only enforced when Turnstile keys are configured AND the request is
// on the primary host). Custom domains can't render the global Turnstile widget
// (its hostname allowlist), so we don't require a token there — those forms are
// covered by the per-IP rate limit above.
const host = await requestHostname();
const captchaApplies = captchaEnabled() && (!host || isPrimaryHostname(host));
if (captchaApplies) {
const token = (body as { captchaToken?: unknown }).captchaToken;
const ok = await verifyCaptcha(typeof token === "string" ? token : undefined, ip);
if (!ok) {
return NextResponse.json(
{ error: "Captcha verification failed. Please try again." },
{ status: 400 },
);
}
}

const form = await prisma.form.findUnique({
where: { slug },
select: {
Expand All @@ -115,6 +99,24 @@ export async function POST(
return NextResponse.json({ error: "Form not available." }, { status: 404 });
}

// Captcha: on the primary host the global Turnstile applies; on a custom domain
// the guild's OWN Turnstile (if configured), verified with that guild's secret.
// Unconfigured custom domains rely on the per-IP rate limit above.
const host = await requestHostname();
const onPrimary = !host || isPrimaryHostname(host);
const captchaSecret = onPrimary ? undefined : ((await getGuildCaptchaSecret(form.guildId)) ?? undefined);
const captchaRequired = onPrimary ? captchaEnabled() : Boolean(captchaSecret);
if (captchaRequired) {
const token = (body as { captchaToken?: unknown }).captchaToken;
const ok = await verifyCaptcha(typeof token === "string" ? token : undefined, ip, captchaSecret);
if (!ok) {
return NextResponse.json(
{ error: "Captcha verification failed. Please try again." },
{ status: 400 },
);
}
}

// Scheduling window: reject before it opens or after it closes.
if (!isFormOpenNow(form.openAt, form.closeAt, new Date())) {
return NextResponse.json({ error: "This form isn’t open right now." }, { status: 403 });
Expand Down
83 changes: 83 additions & 0 deletions apps/web/src/app/api/guilds/[guildId]/captcha/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { prisma } from "@msk-forms/db";
import { NextResponse, type NextRequest } from "next/server";

import { getCurrentUser } from "@/lib/auth";
import { encryptSecret } from "@/lib/crypto";
import { canManageForms } from "@/lib/guild";
import { isGuildPro } from "@/lib/plan";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
* Set the guild's own Cloudflare Turnstile keys (Pro+). Lets the captcha work on
* the guild's custom domain, where the global site key (hostname-bound) can't.
* Owner/admin only. The secret is stored encrypted; an empty secret keeps the
* existing one.
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ guildId: string }> },
) {
const { guildId } = await params;

const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
if (!(await canManageForms(guildId, user.id))) {
return NextResponse.json({ error: "Forbidden." }, { status: 403 });
}
if (!(await isGuildPro(guildId))) {
return NextResponse.json({ error: "Pro plan required.", code: "pro_required" }, { status: 402 });
}

const body = (await request.json().catch(() => null)) as
| { siteKey?: unknown; secret?: unknown }
| null;
const siteKey = typeof body?.siteKey === "string" ? body.siteKey.trim() : "";
const secret = typeof body?.secret === "string" ? body.secret.trim() : "";

if (!siteKey || siteKey.length > 100) {
return NextResponse.json({ error: "Enter a valid Turnstile site key." }, { status: 422 });
}
if (secret.length > 200) {
return NextResponse.json({ error: "Invalid secret." }, { status: 422 });
}

const existing = await prisma.guild.findUnique({
where: { id: guildId },
select: { captchaSecret: true },
});

let secretToStore = existing?.captchaSecret ?? null;
if (secret) {
secretToStore = encryptSecret(secret);
} else if (!secretToStore) {
return NextResponse.json({ error: "Enter the secret key." }, { status: 422 });
}

await prisma.guild.update({
where: { id: guildId },
data: { captchaSiteKey: siteKey, captchaSecret: secretToStore },
});
return NextResponse.json({ ok: true, hasSecret: true });
}

/** Remove the guild's custom Turnstile keys. Owner/admin only. */
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ guildId: string }> },
) {
const { guildId } = await params;

const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
if (!(await canManageForms(guildId, user.id))) {
return NextResponse.json({ error: "Forbidden." }, { status: 403 });
}

await prisma.guild.update({
where: { id: guildId },
data: { captchaSiteKey: null, captchaSecret: null },
});
return NextResponse.json({ ok: true });
}
12 changes: 11 additions & 1 deletion apps/web/src/app/dashboard/[guildId]/domain/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { prisma } from "@msk-forms/db";
import { Card } from "@msk-forms/ui";

import { UpgradeActions } from "@/components/billing/upgrade-button";
import { CaptchaForm } from "@/components/domain/captcha-form";
import { DomainForm } from "@/components/domain/domain-form";
import { OAuthForm } from "@/components/domain/oauth-form";
import { ProNotice } from "@/components/pro-notice";
Expand Down Expand Up @@ -62,8 +63,11 @@ export default async function DomainPage({
customDomainVerifiedAt: true,
oauthClientId: true,
oauthClientSecret: true,
captchaSiteKey: true,
captchaSecret: true,
},
});
const verifiedDomain = guild?.customDomainVerifiedAt ? (guild.customDomain ?? "") : "";

return (
<div className="flex flex-col gap-4">
Expand All @@ -83,10 +87,16 @@ export default async function DomainPage({
/>
<OAuthForm
guildId={guildId}
customDomain={guild?.customDomainVerifiedAt ? (guild.customDomain ?? "") : ""}
customDomain={verifiedDomain}
initial={{ clientId: guild?.oauthClientId ?? "", hasSecret: Boolean(guild?.oauthClientSecret) }}
t={t.oauth}
/>
<CaptchaForm
guildId={guildId}
customDomain={verifiedDomain}
initial={{ siteKey: guild?.captchaSiteKey ?? "", hasSecret: Boolean(guild?.captchaSecret) }}
t={t.captcha}
/>
</div>
);
}
7 changes: 4 additions & 3 deletions apps/web/src/app/f/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { PoweredBy } from "@/components/public/powered-by";
import { experimentCookieName } from "@/lib/experiment";
import { getCurrentUser } from "@/lib/auth";
import { brandStyle, logoUrl, parseBranding } from "@/lib/branding";
import { getGuildCaptchaSiteKey } from "@/lib/guild-captcha";
import { getGuildByDomain, isPrimaryHostname, requestHostname } from "@/lib/custom-domain";
import { isGuildPro } from "@/lib/plan";
import { captchaSiteKey } from "@/lib/captcha";
Expand Down Expand Up @@ -105,9 +106,9 @@ export default async function PublicFormPage({
}

// The global Turnstile sitekey is bound to the primary host's allowlist, so it
// can't render on customer custom domains. Skip the widget there — those forms
// stay protected by the per-IP rate limit on submit (see the submit route).
const siteKey = onCustomDomain ? null : captchaSiteKey();
// can't render on customer custom domains. There we use the guild's OWN
// Turnstile site key if configured; otherwise no widget (rate limit only).
const siteKey = onCustomDomain ? await getGuildCaptchaSiteKey(form.guildId) : captchaSiteKey();
const nonce = (await headers()).get("x-nonce") ?? undefined;

// A/B test: assign a variant (sticky cookie, else weighted-random) and show
Expand Down
127 changes: 127 additions & 0 deletions apps/web/src/components/domain/captcha-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"use client";

import { Button, Card, Field, Input } from "@msk-forms/ui";
import { useState } from "react";

import type { Dictionary } from "@/i18n";

type CaptchaDict = Dictionary["domain"]["captcha"];

export function CaptchaForm({
guildId,
customDomain,
initial,
t,
}: {
guildId: string;
/** The verified custom domain, or "" when none is verified yet. */
customDomain: string;
initial: { siteKey: string; hasSecret: boolean };
t: CaptchaDict;
}) {
const [siteKey, setSiteKey] = useState(initial.siteKey);
const [secret, setSecret] = useState("");
const [hasSecret, setHasSecret] = useState(initial.hasSecret);
const [saving, setSaving] = useState(false);
const [removing, setRemoving] = useState(false);
const [error, setError] = useState<string | null>(null);

const configured = Boolean(initial.siteKey) && initial.hasSecret;

async function save() {
setError(null);
setSaving(true);
try {
const res = await fetch(`/api/guilds/${guildId}/captcha`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ siteKey: siteKey.trim(), secret: secret.trim() }),
});
const data = (await res.json().catch(() => null)) as { error?: string } | null;
if (!res.ok) throw new Error(data?.error ?? t.errSave);
setHasSecret(true);
setSecret("");
} catch (err) {
setError(err instanceof Error ? err.message : t.errSave);
} finally {
setSaving(false);
}
}

async function remove() {
setError(null);
setRemoving(true);
try {
const res = await fetch(`/api/guilds/${guildId}/captcha`, { method: "DELETE" });
if (!res.ok) throw new Error(t.errAction);
setSiteKey("");
setSecret("");
setHasSecret(false);
} catch (err) {
setError(err instanceof Error ? err.message : t.errAction);
} finally {
setRemoving(false);
}
}

return (
<Card className="flex flex-col gap-4 p-5">
<div className="flex items-center justify-between gap-2">
<h3 className="font-heading text-lg font-semibold text-foreground">{t.title}</h3>
{configured && (
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{t.active}
</span>
)}
</div>
<p className="text-sm text-muted-foreground">{t.intro}</p>

{!customDomain ? (
<p className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{t.needDomain}
</p>
) : (
<>
<ol className="ms-4 flex list-decimal flex-col gap-1 text-sm text-muted-foreground">
<li>{t.step1}</li>
<li>
{t.step2} <span className="font-mono text-foreground">{customDomain}</span>
</li>
<li>{t.step3}</li>
</ol>

<Field label={t.siteKey}>
<Input
value={siteKey}
onChange={(e) => setSiteKey(e.target.value)}
placeholder="0x4AAAAAAA…"
/>
</Field>

<Field label={t.secretLabel}>
<Input
type="password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder={hasSecret ? t.secretPlaceholderSet : t.secretPlaceholderNew}
autoComplete="off"
/>
</Field>

{error && <p className="text-sm text-destructive">{error}</p>}

<div className="flex items-center gap-2">
<Button type="button" onClick={save} disabled={saving || !siteKey.trim()}>
{saving ? t.saving : t.save}
</Button>
{configured && (
<Button type="button" variant="ghost" onClick={remove} disabled={removing}>
{removing ? t.removing : t.remove}
</Button>
)}
</div>
</>
)}
</Card>
);
}
42 changes: 42 additions & 0 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,20 @@ const en = {
active: "Active", save: "Save", saving: "Saving…", remove: "Remove", removing: "Removing…",
errSave: "Could not save the credentials.", errAction: "Action failed.",
},
captcha: {
title: "Own captcha (Cloudflare Turnstile)",
intro: "Use your own Turnstile widget so the captcha works on your custom domain (the default one is bound to our domain).",
needDomain: "Verify a custom domain first — the captcha runs on it.",
step1: "Create a Turnstile widget at dash.cloudflare.com → Turnstile.",
step2: "Under Hostname Management add your domain:",
step3: "Copy the Site Key and Secret Key into the fields below.",
siteKey: "Site Key",
secretLabel: "Secret Key",
secretPlaceholderSet: "•••••••• (unchanged — leave blank to keep)",
secretPlaceholderNew: "Secret key",
active: "Active", save: "Save", saving: "Saving…", remove: "Remove", removing: "Removing…",
errSave: "Could not save the keys.", errAction: "Action failed.",
},
},
api: {
title: "API",
Expand Down Expand Up @@ -772,6 +786,20 @@ const de: Dictionary = {
active: "Aktiv", save: "Speichern", saving: "Wird gespeichert…", remove: "Entfernen", removing: "Wird entfernt…",
errSave: "Credentials konnten nicht gespeichert werden.", errAction: "Aktion fehlgeschlagen.",
},
captcha: {
title: "Eigenes Captcha (Cloudflare Turnstile)",
intro: "Nutze dein eigenes Turnstile-Widget, damit das Captcha auf deiner Custom-Domain funktioniert (das Standard-Widget ist an unsere Domain gebunden).",
needDomain: "Verifiziere zuerst eine Custom-Domain — das Captcha läuft darauf.",
step1: "Erstelle ein Turnstile-Widget auf dash.cloudflare.com → Turnstile.",
step2: "Füge unter „Hostname Management“ deine Domain hinzu:",
step3: "Kopiere Site Key und Secret Key in die Felder unten.",
siteKey: "Site Key",
secretLabel: "Secret Key",
secretPlaceholderSet: "•••••••• (unverändert — leer lassen, um es zu behalten)",
secretPlaceholderNew: "Secret Key",
active: "Aktiv", save: "Speichern", saving: "Wird gespeichert…", remove: "Entfernen", removing: "Wird entfernt…",
errSave: "Keys konnten nicht gespeichert werden.", errAction: "Aktion fehlgeschlagen.",
},
},
api: {
title: "API",
Expand Down Expand Up @@ -1214,6 +1242,20 @@ const hu: Dictionary = {
active: "Aktív", save: "Mentés", saving: "Mentés…", remove: "Eltávolítás", removing: "Eltávolítás…",
errSave: "A hitelesítő adatok mentése sikertelen.", errAction: "A művelet sikertelen.",
},
captcha: {
title: "Saját captcha (Cloudflare Turnstile)",
intro: "Használd a saját Turnstile-widgetedet, hogy a captcha az egyéni domaineden is működjön (az alapértelmezett a mi domainünkhöz van kötve).",
needDomain: "Előbb ellenőrizz egy egyéni domaint — a captcha azon fog futni.",
step1: "Hozz létre egy Turnstile-widgetet a dash.cloudflare.com → Turnstile oldalon.",
step2: "A „Hostname Management“ alatt add hozzá a domained:",
step3: "Másold a Site Key és Secret Key értékeket az alábbi mezőkbe.",
siteKey: "Site Key",
secretLabel: "Secret Key",
secretPlaceholderSet: "•••••••• (változatlan — hagyd üresen a megtartásához)",
secretPlaceholderNew: "Secret Key",
active: "Aktív", save: "Mentés", saving: "Mentés…", remove: "Eltávolítás", removing: "Eltávolítás…",
errSave: "A kulcsok mentése sikertelen.", errAction: "A művelet sikertelen.",
},
},
api: {
title: "API",
Expand Down
Loading