Skip to content
Open
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
13 changes: 13 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -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://<project-id>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
105 changes: 105 additions & 0 deletions frontend/app/feedback/action.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -27,6 +34,7 @@ type FeedbackPayload = {
memoryGB?: string;
screenshotPaths?: string[];
categories?: string[];
recaptchaToken?: string;
};

type ActionResult =
Expand All @@ -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
Expand Down Expand Up @@ -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<ActionResult> {
Expand All @@ -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) {
Expand Down Expand Up @@ -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 };
}
107 changes: 101 additions & 6 deletions frontend/app/feedback/feedback-form.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import Script from "next/script";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import {
Bug,
Expand Down Expand Up @@ -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 {
Expand All @@ -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<string>;
};

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<string>((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
Expand All @@ -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<Screenshot[]>([]);
const [fileError, setFileError] = useState<string | null>(null);
const [steps, setSteps] = useState<Step[]>(freshSteps);
Expand Down Expand Up @@ -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");
Expand All @@ -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());
Expand All @@ -292,7 +380,9 @@ export function FeedbackForm() {
}

const submitLabel =
phase === "uploading"
phase === "verifying"
? "Verifying..."
: phase === "uploading"
? "Uploading screenshots..."
: pending
? "Submitting..."
Expand Down Expand Up @@ -342,7 +432,11 @@ export function FeedbackForm() {
}

return (
<form action={handleSubmit} className="mt-8 space-y-5">
<>
{recaptchaScriptSrc && (
<Script src={recaptchaScriptSrc} strategy="afterInteractive" />
)}
<form action={handleSubmit} className="mt-8 space-y-5">
{/* Type toggle */}
<fieldset className="grid grid-cols-2 gap-2">
<button
Expand Down Expand Up @@ -742,6 +836,7 @@ export function FeedbackForm() {
>
{submitLabel}
</TabbyButton>
</form>
</form>
</>
);
}
Loading
Loading