From fa3a6756a045c8657b385031eff0d8cba287d39e Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:14:04 -0400 Subject: [PATCH 1/3] feat: implement feedback rate limiting functionality - Added constants for feedback rate limiting including window duration and storage keys. - Implemented `getFeedbackRateLimitWaitMs` function to calculate wait time based on submission timestamps. - Created `formatFeedbackRateLimitWait` function to format wait time into user-friendly text. - Added unit tests for feedback rate limiting logic to ensure correct behavior under various scenarios. chore: update dependencies - Upgraded Next.js from 16.2.3 to 16.2.9. - Updated various Babel packages to version 7.29.7. - Updated other dependencies including nanoid and postcss to their latest versions. --- frontend/app/feedback/action.ts | 27 +++ frontend/app/feedback/feedback-form.tsx | 22 ++ frontend/app/lib/feedback.test.ts | 35 +++ frontend/app/lib/feedback.ts | 19 ++ frontend/package-lock.json | 300 ++++++++++++++---------- frontend/package.json | 2 +- package-lock.json | 6 + 7 files changed, 290 insertions(+), 121 deletions(-) create mode 100644 package-lock.json diff --git a/frontend/app/feedback/action.ts b/frontend/app/feedback/action.ts index 03fed6c..109dfa6 100644 --- a/frontend/app/feedback/action.ts +++ b/frontend/app/feedback/action.ts @@ -1,8 +1,13 @@ "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, @@ -172,6 +177,21 @@ 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 labels: string[] = data.type === "bug" ? ["bug"] : ["enhancement"]; if (data.type === "bug" && data.categories && data.categories.length > 0) { @@ -202,5 +222,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..e0b7be2 100644 --- a/frontend/app/feedback/feedback-form.tsx +++ b/frontend/app/feedback/feedback-form.tsx @@ -33,6 +33,9 @@ import { getSupabase } from "@/app/lib/supabase"; import { ALLOWED_IMAGE_TYPES, FEEDBACK_BUCKET, + FEEDBACK_RATE_LIMIT_STORAGE_KEY, + formatFeedbackRateLimitWait, + getFeedbackRateLimitWaitMs, MAX_SCREENSHOTS, MAX_SCREENSHOT_BYTES, } from "@/app/lib/feedback"; @@ -245,6 +248,21 @@ 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; + } + let screenshotPaths: string[] | undefined; if (screenshots.length > 0) { setPhase("uploading"); @@ -275,6 +293,10 @@ export function FeedbackForm() { 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()); diff --git a/frontend/app/lib/feedback.test.ts b/frontend/app/lib/feedback.test.ts index fb9263a..377664e 100644 --- a/frontend/app/lib/feedback.test.ts +++ b/frontend/app/lib/feedback.test.ts @@ -4,6 +4,9 @@ import assert from "node:assert/strict"; import { ALLOWED_IMAGE_TYPES, + FEEDBACK_RATE_LIMIT_WINDOW_MS, + formatFeedbackRateLimitWait, + getFeedbackRateLimitWaitMs, IMAGE_TYPE_EXTENSIONS, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOTS, @@ -42,3 +45,35 @@ test("image type table and limits stay consistent", () => { assert.equal(MAX_SCREENSHOT_BYTES, 5_000_000); assert.ok(MAX_SCREENSHOTS > 0); }); + +test("feedback rate limit allows submissions after 30 minutes", () => { + const now = 1_000_000; + + assert.equal( + getFeedbackRateLimitWaitMs(now - FEEDBACK_RATE_LIMIT_WINDOW_MS, now), + 0, + ); + assert.equal( + getFeedbackRateLimitWaitMs(now - FEEDBACK_RATE_LIMIT_WINDOW_MS - 1, now), + 0, + ); +}); + +test("feedback rate limit blocks submissions inside the 30 minute window", () => { + const now = 10_000_000; + + assert.equal(getFeedbackRateLimitWaitMs(now, now), FEEDBACK_RATE_LIMIT_WINDOW_MS); + assert.equal(getFeedbackRateLimitWaitMs(now - 29 * 60_000, now), 60_000); +}); + +test("feedback rate limit ignores missing or invalid timestamps", () => { + assert.equal(getFeedbackRateLimitWaitMs(Number.NaN, 1_000_000), 0); + assert.equal(getFeedbackRateLimitWaitMs(0, 1_000_000), 0); + assert.equal(getFeedbackRateLimitWaitMs(-1, 1_000_000), 0); +}); + +test("feedback rate limit wait text rounds up to minutes", () => { + assert.equal(formatFeedbackRateLimitWait(1), "1 minute"); + assert.equal(formatFeedbackRateLimitWait(60_000), "1 minute"); + assert.equal(formatFeedbackRateLimitWait(60_001), "2 minutes"); +}); diff --git a/frontend/app/lib/feedback.ts b/frontend/app/lib/feedback.ts index a7dd338..fb3009f 100644 --- a/frontend/app/lib/feedback.ts +++ b/frontend/app/lib/feedback.ts @@ -9,6 +9,10 @@ export const MAX_SCREENSHOTS = 4; // so client-side validation and the bucket's hard limit agree exactly. export const MAX_SCREENSHOT_BYTES = 5_000_000; +export const FEEDBACK_RATE_LIMIT_COOKIE = "tabby_feedback_submitted_at"; +export const FEEDBACK_RATE_LIMIT_STORAGE_KEY = "tabby.feedback.submittedAt"; +export const FEEDBACK_RATE_LIMIT_WINDOW_MS = 30 * 60 * 1000; + // Allowed image MIME types mapped to the extension we store them under. export const IMAGE_TYPE_EXTENSIONS: Record = { "image/png": "png", @@ -24,3 +28,18 @@ export const ALLOWED_IMAGE_TYPES = Object.keys(IMAGE_TYPE_EXTENSIONS); // inject arbitrary image URLs into the issues we create. export const SCREENSHOT_PATH_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpe?g|gif|webp)$/; + +export function getFeedbackRateLimitWaitMs( + lastSubmittedAtMs: number, + nowMs: number, +): number { + if (!Number.isFinite(lastSubmittedAtMs) || lastSubmittedAtMs <= 0) { + return 0; + } + return Math.max(0, FEEDBACK_RATE_LIMIT_WINDOW_MS - (nowMs - lastSubmittedAtMs)); +} + +export function formatFeedbackRateLimitWait(waitMs: number): string { + const minutes = Math.max(1, Math.ceil(waitMs / 60_000)); + return minutes === 1 ? "1 minute" : `${minutes} minutes`; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b18a6d8..1d7ced8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,7 +13,7 @@ "@vercel/speed-insights": "^2.0.0", "framer-motion": "^12.38.0", "lucide-react": "^1.16.0", - "next": "16.2.3", + "next": "^16.2.9", "react": "19.2.4", "react-dom": "19.2.4" }, @@ -42,13 +42,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -57,9 +57,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -98,14 +98,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -115,14 +115,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -132,9 +132,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -142,29 +142,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -174,9 +174,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -184,9 +184,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -194,9 +194,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -218,13 +218,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -234,33 +234,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -268,14 +268,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -603,6 +603,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -619,6 +622,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -635,6 +641,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -651,6 +660,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -667,6 +679,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -683,6 +698,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -699,6 +717,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -715,6 +736,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -731,6 +755,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -753,6 +780,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -775,6 +805,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -797,6 +830,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -819,6 +855,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -841,6 +880,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -863,6 +905,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -885,6 +930,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1040,9 +1088,9 @@ } }, "node_modules/@next/env": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.3.tgz", - "integrity": "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.9.tgz", + "integrity": "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1056,9 +1104,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.3.tgz", - "integrity": "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz", + "integrity": "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==", "cpu": [ "arm64" ], @@ -1072,9 +1120,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.3.tgz", - "integrity": "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz", + "integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==", "cpu": [ "x64" ], @@ -1088,12 +1136,15 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.3.tgz", - "integrity": "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz", + "integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1104,12 +1155,15 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.3.tgz", - "integrity": "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz", + "integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1120,12 +1174,15 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.3.tgz", - "integrity": "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz", + "integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1136,12 +1193,15 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.3.tgz", - "integrity": "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz", + "integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1152,9 +1212,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.3.tgz", - "integrity": "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz", + "integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==", "cpu": [ "arm64" ], @@ -1168,9 +1228,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.3.tgz", - "integrity": "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz", + "integrity": "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==", "cpu": [ "x64" ], @@ -1863,9 +1923,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -4068,9 +4128,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -5148,9 +5208,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -5189,12 +5249,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.3.tgz", - "integrity": "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.9.tgz", + "integrity": "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==", "license": "MIT", "dependencies": { - "@next/env": "16.2.3", + "@next/env": "16.2.9", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -5208,14 +5268,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.3", - "@next/swc-darwin-x64": "16.2.3", - "@next/swc-linux-arm64-gnu": "16.2.3", - "@next/swc-linux-arm64-musl": "16.2.3", - "@next/swc-linux-x64-gnu": "16.2.3", - "@next/swc-linux-x64-musl": "16.2.3", - "@next/swc-win32-arm64-msvc": "16.2.3", - "@next/swc-win32-x64-msvc": "16.2.3", + "@next/swc-darwin-arm64": "16.2.9", + "@next/swc-darwin-x64": "16.2.9", + "@next/swc-linux-arm64-gnu": "16.2.9", + "@next/swc-linux-arm64-musl": "16.2.9", + "@next/swc-linux-x64-gnu": "16.2.9", + "@next/swc-linux-x64-musl": "16.2.9", + "@next/swc-win32-arm64-msvc": "16.2.9", + "@next/swc-win32-x64-msvc": "16.2.9", "sharp": "^0.34.5" }, "peerDependencies": { @@ -5556,9 +5616,9 @@ } }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -5576,7 +5636,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5954,9 +6014,9 @@ } }, "node_modules/sharp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "license": "ISC", "optional": true, "bin": { diff --git a/frontend/package.json b/frontend/package.json index 5f03151..bd154f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,7 @@ "@vercel/speed-insights": "^2.0.0", "framer-motion": "^12.38.0", "lucide-react": "^1.16.0", - "next": "16.2.3", + "next": "^16.2.9", "react": "19.2.4", "react-dom": "19.2.4" }, diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..34fbbce --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "tabby-landing", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From 4467dec2e48c9fd4bbf7e6c2a2a2a29a0500b39b Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:17:21 -0400 Subject: [PATCH 2/3] fix: shorten feedback rate limit window --- frontend/app/lib/feedback.test.ts | 6 +++--- frontend/app/lib/feedback.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/app/lib/feedback.test.ts b/frontend/app/lib/feedback.test.ts index 377664e..2b82fb6 100644 --- a/frontend/app/lib/feedback.test.ts +++ b/frontend/app/lib/feedback.test.ts @@ -46,7 +46,7 @@ test("image type table and limits stay consistent", () => { assert.ok(MAX_SCREENSHOTS > 0); }); -test("feedback rate limit allows submissions after 30 minutes", () => { +test("feedback rate limit allows submissions after 10 minutes", () => { const now = 1_000_000; assert.equal( @@ -59,11 +59,11 @@ test("feedback rate limit allows submissions after 30 minutes", () => { ); }); -test("feedback rate limit blocks submissions inside the 30 minute window", () => { +test("feedback rate limit blocks submissions inside the 10 minute window", () => { const now = 10_000_000; assert.equal(getFeedbackRateLimitWaitMs(now, now), FEEDBACK_RATE_LIMIT_WINDOW_MS); - assert.equal(getFeedbackRateLimitWaitMs(now - 29 * 60_000, now), 60_000); + assert.equal(getFeedbackRateLimitWaitMs(now - 9 * 60_000, now), 60_000); }); test("feedback rate limit ignores missing or invalid timestamps", () => { diff --git a/frontend/app/lib/feedback.ts b/frontend/app/lib/feedback.ts index fb3009f..284fc67 100644 --- a/frontend/app/lib/feedback.ts +++ b/frontend/app/lib/feedback.ts @@ -11,7 +11,7 @@ export const MAX_SCREENSHOT_BYTES = 5_000_000; export const FEEDBACK_RATE_LIMIT_COOKIE = "tabby_feedback_submitted_at"; export const FEEDBACK_RATE_LIMIT_STORAGE_KEY = "tabby.feedback.submittedAt"; -export const FEEDBACK_RATE_LIMIT_WINDOW_MS = 30 * 60 * 1000; +export const FEEDBACK_RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; // Allowed image MIME types mapped to the extension we store them under. export const IMAGE_TYPE_EXTENSIONS: Record = { From 3f7c9f5c8ad4f740c3da2530f7a9d8134fd7b30d Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:39:18 -0400 Subject: [PATCH 3/3] feat: add reCAPTCHA protection to feedback form --- frontend/.env.example | 13 ++++ frontend/app/feedback/action.ts | 78 +++++++++++++++++++++++ frontend/app/feedback/feedback-form.tsx | 85 +++++++++++++++++++++++-- frontend/app/lib/feedback.test.ts | 22 +++++++ frontend/app/lib/feedback.ts | 13 ++++ 5 files changed, 205 insertions(+), 6 deletions(-) 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 109dfa6..314dfdc 100644 --- a/frontend/app/feedback/action.ts +++ b/frontend/app/feedback/action.ts @@ -11,6 +11,8 @@ import { 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"; @@ -31,6 +33,7 @@ type FeedbackPayload = { memoryGB?: string; screenshotPaths?: string[]; categories?: string[]; + recaptchaToken?: string; }; type ActionResult = @@ -42,6 +45,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 @@ -161,6 +170,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 { @@ -192,6 +265,11 @@ export async function submitFeedback( }; } + 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) { diff --git a/frontend/app/feedback/feedback-form.tsx b/frontend/app/feedback/feedback-form.tsx index e0b7be2..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, @@ -38,6 +39,7 @@ import { getFeedbackRateLimitWaitMs, MAX_SCREENSHOTS, MAX_SCREENSHOT_BYTES, + RECAPTCHA_FEEDBACK_ACTION, } from "@/app/lib/feedback"; import { @@ -55,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 @@ -71,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); @@ -263,6 +320,14 @@ export function FeedbackForm() { 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"); @@ -289,6 +354,7 @@ export function FeedbackForm() { memoryGB: memoryGB.trim() || undefined, screenshotPaths, categories: categories.length > 0 ? categories : undefined, + recaptchaToken: recaptcha.token, }); setPhase("idle"); @@ -314,7 +380,9 @@ export function FeedbackForm() { } const submitLabel = - phase === "uploading" + phase === "verifying" + ? "Verifying..." + : phase === "uploading" ? "Uploading screenshots..." : pending ? "Submitting..." @@ -364,7 +432,11 @@ export function FeedbackForm() { } return ( -
+ <> + {recaptchaScriptSrc && ( +