Skip to content

Commit 0fe8158

Browse files
nullcoderclaude
andauthored
feat: add Cloudflare Turnstile bot protection (#140)
- Add Turnstile React component with invisible mode support - Implement server-side token verification - Integrate into gist creation flow with proper error handling - Add comprehensive documentation and setup guide - Include test keys for local development - Update environment configuration for both public and secret keys The integration uses invisible mode for better UX - users only see a challenge if Cloudflare detects suspicious activity. All errors are displayed clearly with actionable messages. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent 65258bb commit 0fe8158

File tree

12 files changed

+747
-5
lines changed

12 files changed

+747
-5
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,4 @@ yarn-error.log*
4646
# typescript
4747
*.tsbuildinfo
4848
next-env.d.ts
49+
.env

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ Simply open the shared link - the decryption key is in the URL fragment (#key=..
6868
- **Client-Side Encryption**: All encryption/decryption happens in your browser using the Web Crypto API
6969
- **Zero-Knowledge**: Servers only store encrypted blobs - we can't read your content even if we wanted to
7070
- **No Tracking**: No analytics, no cookies, no user tracking
71+
- **Bot Protection**: Optional Cloudflare Turnstile integration for spam prevention
7172
- **Open Source**: Verify our security claims by reviewing the code
7273

7374
## 🛠️ Technical Stack

app/api/gists/route.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { createLogger } from "@/lib/logger";
99
import { createGistMetadataSchema } from "@/lib/api-schemas";
1010
import type { CreateGistResponse } from "@/types/api";
1111
import type { GistMetadata } from "@/types/models";
12+
import { verifyTurnstileToken, isTurnstileEnabled } from "@/lib/turnstile";
13+
import { getCloudflareContext } from "@opennextjs/cloudflare";
1214

1315
/**
1416
* Parse multipart form data from request
@@ -17,13 +19,15 @@ async function parseMultipartFormData(request: NextRequest): Promise<{
1719
metadata: Record<string, unknown>;
1820
blob: Uint8Array;
1921
password?: string;
22+
turnstileToken?: string;
2023
}> {
2124
const formData = await request.formData();
2225

2326
// Get required parts
2427
const metadataFile = formData.get("metadata") as File | null;
2528
const blobFile = formData.get("blob") as File | null;
2629
const passwordValue = formData.get("password") as string | null;
30+
const turnstileToken = formData.get("turnstileToken") as string | null;
2731

2832
if (!metadataFile || !blobFile) {
2933
throw ApiErrors.badRequest(
@@ -48,6 +52,7 @@ async function parseMultipartFormData(request: NextRequest): Promise<{
4852
metadata,
4953
blob,
5054
password: passwordValue || undefined,
55+
turnstileToken: turnstileToken || undefined,
5156
};
5257
}
5358

@@ -59,6 +64,8 @@ const logger = createLogger("api:gists:post");
5964
*/
6065
export async function POST(request: NextRequest) {
6166
try {
67+
// Get Cloudflare environment
68+
const { env } = getCloudflareContext();
6269
// CSRF Protection
6370
if (!validateCSRFProtection(request)) {
6471
logger.warn("CSRF protection failed", {
@@ -81,6 +88,7 @@ export async function POST(request: NextRequest) {
8188
metadata: Record<string, unknown>;
8289
blob: Uint8Array;
8390
password?: string;
91+
turnstileToken?: string;
8492
};
8593
try {
8694
formParts = await parseMultipartFormData(request);
@@ -93,7 +101,31 @@ export async function POST(request: NextRequest) {
93101
);
94102
}
95103

96-
const { metadata: rawMetadata, blob, password } = formParts;
104+
const { metadata: rawMetadata, blob, password, turnstileToken } = formParts;
105+
106+
// Verify Turnstile token if enabled
107+
if (isTurnstileEnabled(env)) {
108+
if (!turnstileToken) {
109+
return errorResponse(
110+
ApiErrors.badRequest("Verification token is required")
111+
);
112+
}
113+
114+
const turnstileResult = await verifyTurnstileToken(
115+
turnstileToken,
116+
env.TURNSTILE_SECRET_KEY!,
117+
request.headers.get("cf-connecting-ip") || undefined
118+
);
119+
120+
if (!turnstileResult.success) {
121+
logger.warn("Turnstile verification failed", {
122+
errorCodes: turnstileResult["error-codes"],
123+
});
124+
return errorResponse(
125+
ApiErrors.badRequest("Verification failed. Please try again.")
126+
);
127+
}
128+
}
97129

98130
// Validate metadata
99131
const validationResult = createGistMetadataSchema.safeParse(rawMetadata);

app/create/page.tsx

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,12 @@ import { Badge } from "@/components/ui/badge";
2727
import { encryptGist } from "@/lib/crypto-utils";
2828
import type { FileData } from "@/components/ui/file-editor";
2929
import { SecurityLoading } from "@/components/security-loading";
30+
import { Turnstile } from "@/components/ui/turnstile";
3031

3132
export default function CreateGistPage() {
33+
// Get Turnstile site key - safe to use on client as it's a public key
34+
const turnstileSiteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
35+
console.log("Turnstile Site Key:", turnstileSiteKey);
3236
const router = useRouter();
3337
const multiFileEditorRef = useRef<MultiFileEditorHandle>(null);
3438
const [files, setFiles] = useState<FileData[]>(() => [
@@ -53,10 +57,12 @@ export default function CreateGistPage() {
5357
const [validationMessage, setValidationMessage] = useState<string | null>(
5458
null
5559
);
60+
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
61+
const [isTurnstileReady, setIsTurnstileReady] = useState(false);
5662

5763
const handleFilesChange = useCallback((newFiles: FileData[]) => {
5864
setFiles(newFiles);
59-
setError(null);
65+
// Don't clear errors on file change - let them persist
6066

6167
// Check for duplicate filenames
6268
const nameCount = new Map<string, number>();
@@ -103,6 +109,7 @@ export default function CreateGistPage() {
103109
try {
104110
setIsCreating(true);
105111
setError(null);
112+
setValidationMessage(null);
106113

107114
// Get current files with their actual content from editors
108115
const currentFiles = multiFileEditorRef.current?.getFiles() || files;
@@ -131,6 +138,14 @@ export default function CreateGistPage() {
131138
return;
132139
}
133140

141+
// Verify Turnstile token
142+
if (!turnstileToken) {
143+
setError(
144+
"🛡️ Security verification required. Please refresh the page and try again."
145+
);
146+
return;
147+
}
148+
134149
// Encrypt the gist on the client side
135150
const encryptedGist = await encryptGist(currentFiles, {
136151
description: description || undefined,
@@ -158,6 +173,9 @@ export default function CreateGistPage() {
158173
formData.append("password", password);
159174
}
160175

176+
// Add Turnstile token
177+
formData.append("turnstileToken", turnstileToken);
178+
161179
// Call the API to create the gist
162180
const response = await fetch("/api/gists", {
163181
method: "POST",
@@ -304,11 +322,43 @@ export default function CreateGistPage() {
304322
</CardContent>
305323
</Card>
306324

325+
{/* Invisible Turnstile Verification */}
326+
{turnstileSiteKey && (
327+
<div className="hidden">
328+
<Turnstile
329+
sitekey={turnstileSiteKey}
330+
onVerify={(token) => {
331+
setTurnstileToken(token);
332+
setIsTurnstileReady(true);
333+
// Don't clear errors here - let them persist
334+
}}
335+
onError={() => {
336+
setError(
337+
"🛡️ Security check failed. Please refresh the page and try again."
338+
);
339+
setIsTurnstileReady(false);
340+
}}
341+
onExpire={() => {
342+
setTurnstileToken(null);
343+
setIsTurnstileReady(false);
344+
setError(
345+
"⏰ Security verification expired. Please refresh the page to continue."
346+
);
347+
}}
348+
theme="auto"
349+
size="invisible"
350+
/>
351+
</div>
352+
)}
353+
307354
{/* Error Display */}
308355
{(error || validationMessage) && (
309356
<Alert variant="destructive">
310357
<AlertCircle className="h-4 w-4" />
311-
<AlertDescription>{error || validationMessage}</AlertDescription>
358+
<AlertDescription>
359+
{validationMessage && <div>{validationMessage}</div>}
360+
{error && <div>{error}</div>}
361+
</AlertDescription>
312362
</Alert>
313363
)}
314364

@@ -317,7 +367,12 @@ export default function CreateGistPage() {
317367
<Button
318368
size="lg"
319369
onClick={handleCreate}
320-
disabled={isCreating || files.length === 0 || hasValidationErrors}
370+
disabled={
371+
isCreating ||
372+
files.length === 0 ||
373+
hasValidationErrors ||
374+
(!!turnstileSiteKey && !isTurnstileReady)
375+
}
321376
>
322377
{isCreating ? (
323378
<LoadingState type="spinner" message="Creating..." />

components/ui/turnstile.test.tsx

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { render, waitFor } from "@testing-library/react";
2+
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
3+
import { Turnstile } from "./turnstile";
4+
5+
// Mock Next.js Script component
6+
vi.mock("next/script", () => ({
7+
default: ({ onLoad }: { onLoad: () => void }) => {
8+
// Simulate script loading
9+
setTimeout(() => onLoad(), 0);
10+
return null;
11+
},
12+
}));
13+
14+
describe("Turnstile", () => {
15+
const mockRender = vi.fn().mockReturnValue("widget-123");
16+
const mockReset = vi.fn();
17+
const mockRemove = vi.fn();
18+
const mockOnVerify = vi.fn();
19+
const mockOnError = vi.fn();
20+
const mockOnExpire = vi.fn();
21+
22+
beforeEach(() => {
23+
// Mock window.turnstile
24+
global.window.turnstile = {
25+
render: mockRender,
26+
reset: mockReset,
27+
remove: mockRemove,
28+
};
29+
30+
vi.clearAllMocks();
31+
});
32+
33+
afterEach(() => {
34+
// Clean up
35+
delete (global.window as any).turnstile;
36+
});
37+
38+
it("renders turnstile widget when script loads", async () => {
39+
const { container } = render(
40+
<Turnstile
41+
sitekey="test-site-key"
42+
onVerify={mockOnVerify}
43+
onError={mockOnError}
44+
onExpire={mockOnExpire}
45+
/>
46+
);
47+
48+
// Wait for script to load and widget to render
49+
await waitFor(() => {
50+
expect(mockRender).toHaveBeenCalledWith(
51+
expect.any(HTMLElement),
52+
expect.objectContaining({
53+
sitekey: "test-site-key",
54+
callback: mockOnVerify,
55+
"error-callback": mockOnError,
56+
"expired-callback": mockOnExpire,
57+
theme: "auto",
58+
size: "normal",
59+
})
60+
);
61+
});
62+
63+
// Check container exists
64+
const turnstileContainer = container.querySelector(".cf-turnstile");
65+
expect(turnstileContainer).toBeInTheDocument();
66+
});
67+
68+
it("applies custom theme and size", async () => {
69+
render(
70+
<Turnstile
71+
sitekey="test-site-key"
72+
onVerify={mockOnVerify}
73+
theme="dark"
74+
size="compact"
75+
/>
76+
);
77+
78+
await waitFor(() => {
79+
expect(mockRender).toHaveBeenCalledWith(
80+
expect.any(HTMLElement),
81+
expect.objectContaining({
82+
theme: "dark",
83+
size: "compact",
84+
})
85+
);
86+
});
87+
});
88+
89+
it("cleans up widget on unmount", async () => {
90+
const { unmount } = render(
91+
<Turnstile sitekey="test-site-key" onVerify={mockOnVerify} />
92+
);
93+
94+
await waitFor(() => {
95+
expect(mockRender).toHaveBeenCalled();
96+
});
97+
98+
unmount();
99+
100+
expect(mockRemove).toHaveBeenCalledWith("widget-123");
101+
});
102+
103+
it("handles render errors gracefully", async () => {
104+
mockRender.mockImplementationOnce(() => {
105+
throw new Error("Render failed");
106+
});
107+
108+
render(
109+
<Turnstile
110+
sitekey="test-site-key"
111+
onVerify={mockOnVerify}
112+
onError={mockOnError}
113+
/>
114+
);
115+
116+
await waitFor(() => {
117+
expect(mockOnError).toHaveBeenCalledWith(
118+
"Failed to load verification widget"
119+
);
120+
});
121+
});
122+
123+
it("applies custom className", () => {
124+
const { container } = render(
125+
<Turnstile
126+
sitekey="test-site-key"
127+
onVerify={mockOnVerify}
128+
className="custom-class"
129+
/>
130+
);
131+
132+
const turnstileContainer = container.querySelector(".cf-turnstile");
133+
expect(turnstileContainer).toHaveClass("custom-class");
134+
});
135+
136+
it("applies correct height classes based on size", () => {
137+
const { container: container1 } = render(
138+
<Turnstile
139+
sitekey="test-site-key"
140+
onVerify={mockOnVerify}
141+
size="compact"
142+
/>
143+
);
144+
expect(container1.querySelector(".cf-turnstile")).toHaveClass("h-[65px]");
145+
146+
const { container: container2 } = render(
147+
<Turnstile
148+
sitekey="test-site-key"
149+
onVerify={mockOnVerify}
150+
size="normal"
151+
/>
152+
);
153+
expect(container2.querySelector(".cf-turnstile")).toHaveClass("h-[65px]");
154+
155+
const { container: container3 } = render(
156+
<Turnstile
157+
sitekey="test-site-key"
158+
onVerify={mockOnVerify}
159+
size="flexible"
160+
/>
161+
);
162+
expect(container3.querySelector(".cf-turnstile")).toHaveClass(
163+
"min-h-[65px]"
164+
);
165+
166+
const { container: container4 } = render(
167+
<Turnstile
168+
sitekey="test-site-key"
169+
onVerify={mockOnVerify}
170+
size="invisible"
171+
/>
172+
);
173+
expect(container4.querySelector(".cf-turnstile")).toHaveClass("h-0");
174+
});
175+
});

0 commit comments

Comments
 (0)