Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add cloudflare turnstile #181

Closed
wants to merge 1 commit into from
Closed
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
3 changes: 3 additions & 0 deletions www/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ NEXT_PUBLIC_STRIPE_ENABLED=false
STRIPE_SECRET_KEY=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=
# Turnstile (optional)
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
# Agent
OPENAI_API_KEY=
MODEL=
Expand Down
28 changes: 27 additions & 1 deletion www/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { thinkCall, respondCall } from './actions';
import { honcho, getHonchoApp } from '@/utils/honcho';
import { streamText } from 'ai';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { ipAddress } from '@vercel/functions';

import * as Sentry from '@sentry/nextjs';

Expand Down Expand Up @@ -131,6 +132,23 @@ async function fetchOpenRouter(type: string, messages: any[], payload: any) {
}
}

async function verifyTurnstile(token: string, ip: string) {
const formData = new FormData();
formData.append('secret', process.env.TURNSTILE_SECRET_KEY as string);
formData.append('response', token);
formData.append('remoteip', ip);

const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const result = await fetch(url, {
body: formData,
method: 'POST',
});

const json = await result.json();

return json.success;
}

Comment on lines +135 to +151
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So from briefly reading about cloudflare turnstiles it does look like a good option if we don't need a manual challenge. I was initially thinking to use the Supabase captcha, but this works.

The main thing we want to do is protect the authentication page to avoid bots and from reading about this we want to have a client side check that is verified on the server.

That makes me think a dedicated server action for cloudflare would make sense that we call on the authentication page as well. We might want to add it to the layout.tsx so it's called on all pages?

export async function POST(req: NextRequest) {
const supabase = createClient();

Expand All @@ -144,7 +162,15 @@ export async function POST(req: NextRequest) {

const data = await req.json();

const { type, message, conversationId, thought } = data;
const { type, message, conversationId, thought, turnstileToken } = data;

if (process.env.TURNSTILE_SECRET_KEY !== null) {
const ip = ipAddress(req);
const isValid = await verifyTurnstile(turnstileToken, ip || '');
if (!isValid) {
return new NextResponse('Invalid turnstile token', { status: 429 });
}
}

console.log("Starting Stream")

Expand Down
212 changes: 128 additions & 84 deletions www/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import dynamic from 'next/dynamic';
import { FaLightbulb, FaPaperPlane } from 'react-icons/fa';
import Swal from 'sweetalert2';

import { useRef, useEffect, useState, ElementRef } from 'react';
import { useRef, useEffect, useState, ElementRef, useMemo } from 'react';
import { useRouter } from 'next/navigation';

import { usePostHog } from 'posthog-js/react';
import Turnstile, { useTurnstile } from 'react-turnstile';

import { getSubscription } from '@/utils/supabase/queries';

Expand All @@ -31,27 +33,39 @@ const Sidebar = dynamic(() => import('@/components/sidebar'), {
ssr: false,
});

async function fetchStream(
type: 'thought' | 'response',
message: string,
conversationId: string,
async function fetchStream({
type,
message,
conversationId,
thought = '',
honchoContent = ''
) {
honchoContent = '',
turnstileToken = null,
}: {
type: 'thought' | 'response';
message: string;
conversationId: string;
thought?: string;
honchoContent?: string;
turnstileToken?: string | null;
}) {
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type,
message,
conversationId,
thought,
honchoContent,
}),
});
const response = await fetch(
`${process.env.NEXT_PUBLIC_SITE_URL}/api/chat`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type,
message,
conversationId,
thought,
honchoContent,
turnstileToken,
}),
}
);

if (!response.ok) {
const errorText = await response.text();
Expand All @@ -60,7 +74,7 @@ async function fetchStream(
statusText: response.statusText,
error: errorText,
});
console.error(response)
console.error(response);
throw new Error(`Failed to fetch ${type} stream: ${response.status}`);
}

Expand All @@ -69,7 +83,9 @@ async function fetchStream(
}

if (!(response.body instanceof ReadableStream)) {
throw new Error(`Response body is not a ReadableStream for ${type} stream`);
throw new Error(
`Response body is not a ReadableStream for ${type} stream`
);
}

return response.body;
Expand Down Expand Up @@ -104,6 +120,11 @@ export default function Home() {
const [isSubscribed, setIsSubscribed] = useState(false);
const [freeMessages, setFreeMessages] = useState<number>(0);

const turnstileEnabled = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY !== null;
const turnstileSiteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || '';
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const turnstile = useTurnstile();

const setIsThoughtsOpen = (
isOpen: boolean,
messageId: string | null = null
Expand Down Expand Up @@ -289,11 +310,12 @@ export default function Home() {

try {
// Get thought stream
const thoughtStream = await fetchStream(
'thought',
const thoughtStream = await fetchStream({
type: 'thought',
message,
conversationId!
);
conversationId: conversationId!,
turnstileToken,
});
if (!thoughtStream) throw new Error('Failed to get thought stream');

thoughtReader = thoughtStream.getReader();
Expand All @@ -317,13 +339,14 @@ export default function Home() {
await new Promise((resolve) => setTimeout(resolve, 5000));

// Get response stream using the thought
const responseStream = await fetchStream(
'response',
const responseStream = await fetchStream({
type: 'response',
message,
conversationId!,
thoughtText,
''
);
conversationId: conversationId!,
thought: thoughtText,
honchoContent: '',
turnstileToken,
});
if (!responseStream) throw new Error('Failed to get response stream');

responseReader = responseStream.getReader();
Expand Down Expand Up @@ -393,7 +416,13 @@ export default function Home() {
}
}

const canUseApp = isSubscribed || freeMessages > 0;
// const canUseApp = isSubscribed || freeMessages > 0;
const canUseApp = useMemo(
() =>
(isSubscribed || freeMessages > 0) &&
(!turnstileEnabled || turnstileToken !== null),
[isSubscribed, freeMessages, turnstileEnabled, turnstileToken]
);

return (
<main className="relative flex h-full overflow-hidden">
Expand Down Expand Up @@ -453,27 +482,27 @@ export default function Home() {
onReactionAdded={handleReactionAdded}
/>
)) || (
<MessageBox
isUser={false}
message={{
content: '',
id: '',
isUser: false,
metadata: { reaction: null },
}}
loading={true}
setThought={setThought}
setIsThoughtsOpen={setIsThoughtsOpen}
onReactionAdded={handleReactionAdded}
userId={userId}
conversationId={conversationId}
/>
)}
<MessageBox
isUser={false}
message={{
content: '',
id: '',
isUser: false,
metadata: { reaction: null },
}}
loading={true}
setThought={setThought}
setIsThoughtsOpen={setIsThoughtsOpen}
onReactionAdded={handleReactionAdded}
userId={userId}
conversationId={conversationId}
/>
)}
</section>
<div className="p-3 pb-0 lg:p-5 lg:pb-0">
<div className="p-3 lg:p-5">
<form
id="send"
className="flex p-3 lg:p-5 gap-3 border-gray-300"
className="flex flex-col items-center gap-3 border-gray-300"
onSubmit={(e) => {
e.preventDefault();
if (canSend && input.current?.value && canUseApp) {
Expand All @@ -482,41 +511,56 @@ export default function Home() {
}
}}
>
<textarea
ref={input}
placeholder={
canUseApp ? 'Type a message...' : 'Subscribe to send messages'
}
className={`flex-1 px-3 py-1 lg:px-5 lg:py-3 bg-gray-100 dark:bg-gray-800 text-gray-400 rounded-2xl border-2 resize-none ${canSend && canUseApp
? 'border-green-200'
: 'border-red-200 opacity-50'
{turnstileEnabled && (
<Turnstile
sitekey={turnstileSiteKey}
appearance="interaction-only"
onVerify={(token) => {
setTurnstileToken(token);
turnstile.remove();
}}
/>
)}
<div className="flex gap-3 w-full">
<textarea
ref={input}
placeholder={
canUseApp
? 'Type a message...'
: 'Subscribe to send messages'
}
className={`flex-1 px-3 py-1 lg:px-5 lg:py-3 bg-gray-100 dark:bg-gray-800 text-gray-400 rounded-2xl border-2 resize-none ${
canSend && canUseApp
? 'border-green-200'
: 'border-red-200 opacity-50'
}`}
rows={1}
disabled={!canUseApp}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (canSend && input.current?.value && canUseApp) {
posthog.capture('user_sent_message');
chat();
rows={1}
disabled={!canUseApp}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (canSend && input.current?.value && canUseApp) {
posthog.capture('user_sent_message');
chat();
}
}
}
}}
/>
<button
className="bg-dark-green text-neon-green rounded-full px-4 py-2 lg:px-7 lg:py-3 flex justify-center items-center gap-2"
type="submit"
disabled={!canSend || !canUseApp}
>
<FaPaperPlane className="inline" />
</button>
<button
className="bg-dark-green text-neon-green rounded-full px-4 py-2 lg:px-7 lg:py-3 flex justify-center items-center gap-2"
onClick={() => setIsThoughtsOpen(true)}
type="button"
>
<FaLightbulb className="inline" />
</button>
}}
/>
<button
className="bg-dark-green text-neon-green rounded-full px-4 py-2 lg:px-7 lg:py-3 flex justify-center items-center gap-2"
type="submit"
disabled={!canSend || !canUseApp}
>
<FaPaperPlane className="inline" />
</button>
<button
className="bg-dark-green text-neon-green rounded-full px-4 py-2 lg:px-7 lg:py-3 flex justify-center items-center gap-2"
onClick={() => setIsThoughtsOpen(true)}
type="button"
>
<FaLightbulb className="inline" />
</button>
</div>
</form>
</div>
</div>
Expand Down
5 changes: 4 additions & 1 deletion www/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@stripe/stripe-js": "^4.9.0",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.45.6",
"@vercel/functions": "^1.5.1",
"@vercel/speed-insights": "^1.1.0",
"ai": "^4.0.1",
"class-variance-authority": "^0.7.0",
Expand All @@ -36,6 +37,7 @@
"react-markdown": "^8.0.7",
"react-syntax-highlighter": "^15.6.1",
"react-toggle-dark-mode": "^1.1.1",
"react-turnstile": "^1.1.4",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"retry": "^0.13.1",
Expand All @@ -45,7 +47,8 @@
"sweetalert2-react-content": "^5.0.7",
"swr": "^2.2.5",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"turnstile-types": "^1.2.3"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
Expand Down
Loading
Loading