diff --git a/frontend/.env.example b/frontend/.env.example index a8b9e24..d62bfa6 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,5 +1,18 @@ GITHUB_TOKEN= +# reCAPTCHA v3 protects the feedback form from automated submissions. +# Generate keys: +# 1. Go to https://www.google.com/recaptcha/admin/create +# 2. Add a label like "Cotabby feedback form". +# 3. Select "Score based (v3)". +# 4. Add your deployment domains, for example cotabby.app and localhost for local testing. +# 5. Copy the site key into NEXT_PUBLIC_RECAPTCHA_SITE_KEY. This key is public and is bundled into the browser by Next.js because of the NEXT_PUBLIC_ prefix. +# 6. Copy the secret key into RECAPTCHA_SECRET_KEY. Keep this server-only and never prefix it with NEXT_PUBLIC_. +# Optional: tune RECAPTCHA_SCORE_THRESHOLD after reviewing scores in the reCAPTCHA admin console. Defaults to 0.5 when unset or invalid. +NEXT_PUBLIC_RECAPTCHA_SITE_KEY= +RECAPTCHA_SECRET_KEY= +RECAPTCHA_SCORE_THRESHOLD=0.5 + NEXT_PUBLIC_SUPABASE_URL=https://.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= diff --git a/frontend/app/feedback/action.ts b/frontend/app/feedback/action.ts index 916d912..e19130f 100644 --- a/frontend/app/feedback/action.ts +++ b/frontend/app/feedback/action.ts @@ -1,11 +1,18 @@ "use server"; +import { cookies } from "next/headers"; import { ALLOWED_IMAGE_TYPES, FEEDBACK_BUCKET, + FEEDBACK_RATE_LIMIT_COOKIE, + FEEDBACK_RATE_LIMIT_WINDOW_MS, + formatFeedbackRateLimitWait, + getFeedbackRateLimitWaitMs, IMAGE_TYPE_EXTENSIONS, MAX_SCREENSHOTS, MAX_SCREENSHOT_BYTES, + parseRecaptchaScoreThreshold, + RECAPTCHA_FEEDBACK_ACTION, SCREENSHOT_PATH_RE, } from "@/app/lib/feedback"; import { getSupabaseAdmin } from "@/app/lib/supabase-admin"; @@ -27,6 +34,7 @@ type FeedbackPayload = { memoryGB?: string; screenshotPaths?: string[]; categories?: string[]; + recaptchaToken?: string; }; type ActionResult = @@ -38,6 +46,12 @@ type UploadTarget = { path: string; token: string }; type UploadUrlsResult = | { success: true; uploads: UploadTarget[] } | { success: false; error: string }; +type RecaptchaVerifyResponse = { + success?: boolean; + score?: number; + action?: string; + "error-codes"?: string[]; +}; // Hands the browser one signed upload URL per file so it can upload directly to // Supabase Storage (bypassing the function body-size limit). The service-role @@ -157,6 +171,70 @@ function buildEnvironmentSection(data: FeedbackPayload): string { return `### Environment\n${lines.join("\n")}\n\n`; } +async function verifyRecaptchaToken( + token?: string, +): Promise<{ success: true } | { success: false; error: string }> { + const secret = process.env.RECAPTCHA_SECRET_KEY; + if (!secret) { + return { + success: false, + error: "Feedback protection is temporarily unavailable.", + }; + } + if (!token) { + return { + success: false, + error: "Please verify the feedback form before submitting.", + }; + } + + const body = new URLSearchParams({ + secret, + response: token, + }); + + let verification: RecaptchaVerifyResponse; + try { + const res = await fetch("https://www.google.com/recaptcha/api/siteverify", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + cache: "no-store", + }); + if (!res.ok) { + return { + success: false, + error: "Could not verify this submission. Please try again.", + }; + } + verification = (await res.json()) as RecaptchaVerifyResponse; + } catch { + return { + success: false, + error: "Could not verify this submission. Please try again.", + }; + } + + const threshold = parseRecaptchaScoreThreshold( + process.env.RECAPTCHA_SCORE_THRESHOLD, + ); + if ( + !verification.success || + verification.action !== RECAPTCHA_FEEDBACK_ACTION || + typeof verification.score !== "number" || + verification.score < threshold + ) { + return { + success: false, + error: "Could not verify this submission. Please try again.", + }; + } + + return { success: true }; +} + export async function submitFeedback( data: FeedbackPayload, ): Promise { @@ -181,6 +259,26 @@ export async function submitFeedback( return { success: false, error: "Description is required." }; } + const cookieStore = await cookies(); + const lastSubmittedAtMs = Number( + cookieStore.get(FEEDBACK_RATE_LIMIT_COOKIE)?.value, + ); + const rateLimitWaitMs = getFeedbackRateLimitWaitMs( + lastSubmittedAtMs, + Date.now(), + ); + if (rateLimitWaitMs > 0) { + return { + success: false, + error: `Please wait ${formatFeedbackRateLimitWait(rateLimitWaitMs)} before submitting feedback again.`, + }; + } + + const recaptcha = await verifyRecaptchaToken(data.recaptchaToken); + if (!recaptcha.success) { + return { success: false, error: recaptcha.error }; + } + const labels: string[] = data.type === "bug" ? ["bug"] : ["enhancement"]; if (data.type === "bug" && data.categories && data.categories.length > 0) { @@ -211,5 +309,12 @@ export async function submitFeedback( } const issue = await res.json(); + cookieStore.set(FEEDBACK_RATE_LIMIT_COOKIE, String(Date.now()), { + httpOnly: true, + maxAge: Math.ceil(FEEDBACK_RATE_LIMIT_WINDOW_MS / 1000), + path: "/feedback", + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + }); return { success: true, issueUrl: issue.html_url }; } diff --git a/frontend/app/feedback/feedback-form.tsx b/frontend/app/feedback/feedback-form.tsx index ec21c57..7e85308 100644 --- a/frontend/app/feedback/feedback-form.tsx +++ b/frontend/app/feedback/feedback-form.tsx @@ -1,5 +1,6 @@ "use client"; +import Script from "next/script"; import { useEffect, useMemo, useRef, useState, useTransition } from "react"; import { Bug, @@ -33,8 +34,12 @@ import { getSupabase } from "@/app/lib/supabase"; import { ALLOWED_IMAGE_TYPES, FEEDBACK_BUCKET, + FEEDBACK_RATE_LIMIT_STORAGE_KEY, + formatFeedbackRateLimitWait, + getFeedbackRateLimitWaitMs, MAX_SCREENSHOTS, MAX_SCREENSHOT_BYTES, + RECAPTCHA_FEEDBACK_ACTION, } from "@/app/lib/feedback"; import { @@ -52,6 +57,61 @@ import { type Step, } from "./feedback-form-utils"; +const recaptchaSiteKey = process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY; +const recaptchaScriptSrc = recaptchaSiteKey + ? `https://www.google.com/recaptcha/api.js?render=${encodeURIComponent(recaptchaSiteKey)}` + : undefined; + +type RecaptchaClient = { + ready: (callback: () => void) => void; + execute: ( + siteKey: string, + options: { action: string }, + ) => Promise; +}; + +declare global { + interface Window { + grecaptcha?: RecaptchaClient; + } +} + +async function createRecaptchaToken(): Promise< + { success: true; token: string } | { success: false; error: string } +> { + if (!recaptchaSiteKey) { + return { + success: false, + error: "Feedback protection is not configured.", + }; + } + + const grecaptcha = window.grecaptcha; + if (!grecaptcha) { + return { + success: false, + error: "Feedback protection is still loading. Please try again.", + }; + } + + try { + const token = await new Promise((resolve, reject) => { + grecaptcha.ready(() => { + grecaptcha + .execute(recaptchaSiteKey, { action: RECAPTCHA_FEEDBACK_ACTION }) + .then(resolve) + .catch(reject); + }); + }); + return { success: true, token }; + } catch { + return { + success: false, + error: "Could not verify this submission. Please try again.", + }; + } +} + export function FeedbackForm() { // `useMemo` keeps the URL read out of the render loop without forcing a state setup. The // `?type=` param can flip the initial Bug/Feature toggle so a "Suggest a feature" entry point @@ -68,9 +128,9 @@ export function FeedbackForm() { const [chip, setChip] = useState(initialEnvironment.chip ?? ""); const [memoryGB, setMemoryGB] = useState(initialEnvironment.memoryGB ?? ""); const [pending, startTransition] = useTransition(); - const [phase, setPhase] = useState<"idle" | "uploading" | "submitting">( - "idle", - ); + const [phase, setPhase] = useState< + "idle" | "verifying" | "uploading" | "submitting" + >("idle"); const [screenshots, setScreenshots] = useState([]); const [fileError, setFileError] = useState(null); const [steps, setSteps] = useState(freshSteps); @@ -245,6 +305,29 @@ export function FeedbackForm() { function handleSubmit(formData: FormData) { setResult(null); startTransition(async () => { + const lastSubmittedAtMs = Number( + window.localStorage.getItem(FEEDBACK_RATE_LIMIT_STORAGE_KEY), + ); + const rateLimitWaitMs = getFeedbackRateLimitWaitMs( + lastSubmittedAtMs, + Date.now(), + ); + if (rateLimitWaitMs > 0) { + setResult({ + success: false, + message: `Please wait ${formatFeedbackRateLimitWait(rateLimitWaitMs)} before submitting feedback again.`, + }); + return; + } + + setPhase("verifying"); + const recaptcha = await createRecaptchaToken(); + if (!recaptcha.success) { + setPhase("idle"); + setResult({ success: false, message: recaptcha.error }); + return; + } + let screenshotPaths: string[] | undefined; if (screenshots.length > 0) { setPhase("uploading"); @@ -271,10 +354,15 @@ export function FeedbackForm() { memoryGB: memoryGB.trim() || undefined, screenshotPaths, categories: categories.length > 0 ? categories : undefined, + recaptchaToken: recaptcha.token, }); setPhase("idle"); if (res.success) { + window.localStorage.setItem( + FEEDBACK_RATE_LIMIT_STORAGE_KEY, + String(Date.now()), + ); screenshots.forEach((s) => URL.revokeObjectURL(s.previewUrl)); setScreenshots([]); setSteps(freshSteps()); @@ -292,7 +380,9 @@ export function FeedbackForm() { } const submitLabel = - phase === "uploading" + phase === "verifying" + ? "Verifying..." + : phase === "uploading" ? "Uploading screenshots..." : pending ? "Submitting..." @@ -342,7 +432,11 @@ export function FeedbackForm() { } return ( -
+ <> + {recaptchaScriptSrc && ( +