Skip to content

Commit

Permalink
Add kasada (vercel-labs#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
jeremyphilemon authored Apr 11, 2024
1 parent 0d29e43 commit 3965598
Show file tree
Hide file tree
Showing 9 changed files with 359 additions and 15 deletions.
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,9 @@ AUTH_SECRET=XXXXXXXX
KV_URL=XXXXXXXX
KV_REST_API_URL=XXXXXXXX
KV_REST_API_TOKEN=XXXXXXXX
KV_REST_API_READ_ONLY_TOKEN=XXXXXXXX
KV_REST_API_READ_ONLY_TOKEN=XXXXXXXX

# Get your kasada configurations here: https://kasada.io
KASADA_API_ENDPOINT=XXXXXXXX
KASADA_API_VERSION=XXXXXXXX
KASADA_HEADER_HOST=XXXXXXXX
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export const runtime = 'edge'
export const dynamic = 'force-dynamic'
export const maxDuration = 3

async function handler(request: Request) {
const url = new URL(request.url)

url.protocol = 'https:'
url.host = process.env.KASADA_API_ENDPOINT || ''
url.port = ''
url.searchParams.delete('restpath')

const headers = new Headers(request.headers)
headers.set('X-Forwarded-Host', process.env.KASADA_HEADER_HOST || '')
headers.delete('host')
const r = await fetch(url.toString(), {
method: request.method,
body: request.body,
headers,
mode: request.mode,
redirect: 'manual',
// @ts-expect-error
duplex: 'half'
})
const responseHeaders = new Headers(r.headers)
responseHeaders.set('cdn-cache-control', 'no-cache')
return new Response(r.body, {
status: r.status,
statusText: r.statusText,
headers: responseHeaders
})
}

export const GET = handler
export const POST = handler
export const OPTIONS = handler
export const PUT = handler
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { TailwindIndicator } from '@/components/tailwind-indicator'
import { Providers } from '@/components/providers'
import { Header } from '@/components/header'
import { Toaster } from '@/components/ui/sonner'
import { KasadaClient } from '@/lib/kasada/kasada-client'

export const metadata = {
metadataBase: new URL(`https://${process.env.VERCEL_URL}`),
Expand Down Expand Up @@ -44,6 +45,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
GeistMono.variable
)}
>
<KasadaClient />
<Toaster position="top-center" />
<Providers
attribute="class"
Expand Down
33 changes: 26 additions & 7 deletions components/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { AI } from '@/lib/chat/actions'
import { nanoid } from 'nanoid'
import { UserMessage } from './stocks/message'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'

export interface ChatPanelProps {
id?: string
Expand Down Expand Up @@ -74,14 +75,32 @@ export function ChatPanel({
}
])

const responseMessage = await submitUserMessage(
example.message
)
try {
const responseMessage = await submitUserMessage(
example.message
)

setMessages(currentMessages => [
...currentMessages,
responseMessage
])
setMessages(currentMessages => [
...currentMessages,
responseMessage
])
} catch {
toast(
<div className="text-red-600">
You have reached your message limit! Please try again
later, or{' '}
<a
className="underline"
target="_blank"
rel="noopener noreferrer"
href="https://vercel.com/templates/next.js/gemini-ai-chatbot"
>
deploy your own version
</a>
.
</div>
)
}
}}
>
<div className="font-medium">{example.heading}</div>
Expand Down
23 changes: 20 additions & 3 deletions components/prompt-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,26 @@ export function PromptForm({
}
])

// Submit and get response message
const responseMessage = await submitUserMessage(value)
setMessages(currentMessages => [...currentMessages, responseMessage])
try {
// Submit and get response message
const responseMessage = await submitUserMessage(value)
setMessages(currentMessages => [...currentMessages, responseMessage])
} catch {
toast(
<div className="text-red-600">
You have reached your message limit! Please try again later, or{' '}
<a
className="underline"
target="_blank"
rel="noopener noreferrer"
href="https://vercel.com/templates/next.js/gemini-ai-chatbot"
>
deploy your own version
</a>
.
</div>
)
}
}}
>
<input
Expand Down
30 changes: 30 additions & 0 deletions lib/kasada/kasada-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Script from 'next/script'

export function KasadaClient() {
return (
<>
<script
dangerouslySetInnerHTML={{
__html:
`document.addEventListener('kpsdk-load', () => {window.KPSDK.configure([
{
domain: location.host,
path: '/',
method: 'POST'
},
{
domain: location.host,
path: '/chat/*',
method: 'POST'
},
]);
});`.replace(/[\n\r\s]/g, '')
}}
></script>
<Script
async={true}
src="/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/p.js"
></Script>
</>
)
}
212 changes: 212 additions & 0 deletions lib/kasada/kasada-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server'
import { track } from '@vercel/analytics/server'

// You can get this endpoint name from the application details on the Kasada Portal.
const kasadaAPIEndpoint = process.env.KASADA_API_ENDPOINT
const kasadaAPIVersion = process.env.KASADA_API_VERSION
const kasadaAPIURL = `https://${kasadaAPIEndpoint}/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/api/${kasadaAPIVersion}/classification`

export interface APIRequest {
// valid IPv4 orIPv6 address of the original client making the request
clientIp: string
// always provide as many of the available header from the client request
headers: Array<{
key: string
value: string
}>
method: 'HEAD' | 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
protocol: 'HTTP' | 'HTTPS'
// /some/path
path: string
// request querystring including leading '?', e.g. '?foo=bar&bar=foo'
querystring: string
// always provide the (redacted) body if available in the client request
body?: string
}

export interface APIResponse {
// unique request id as generated by the API
requestId: string
// unique client id; only present when a client ID is available
clientId?: string
// API classification
classification: 'ALLOWED' | 'BAD-BOT' | 'GOOD-BOT' | 'HUMAN'
// array of Set-Cookie strings, like '<cookie-name>=<cookie-value>; SameSite=None; Secure'
responseHeadersToSet: Array<{ key: string; value: string }>
application: {
mode: 'MONITOR' | 'PROTECT' | 'PASS_THROUGH'
domain: string
}
error: string
}

/**
* Function that fetches the Kasada classification and metadata about the request
* and returns either this metadata or an error if something went wrong.
*/
async function getKasadaMetadata(request: NextRequest): Promise<{
metadata?: APIResponse
error?: Error
}> {
const url = new URL(request.url)

const headers = new Headers(request.headers)
headers.delete('x-forwarded-host')
headers.set('Host', process.env.KASADA_HEADER_HOST || '')

const headersArray = [...headers.entries()].map(([key, value]) => ({
key,
value
}))

const kasadaPayload: APIRequest = {
clientIp: String(request.headers.get('x-real-ip') || request.ip),
headers: headersArray,
method: request.method as APIRequest['method'],
protocol: url.protocol.slice(0, -1).toUpperCase() as APIRequest['protocol'],
path: url.pathname,
querystring: url.search
}

// Set a maximum Kasada response time of 3 seconds
const timeout = 3000
const timeoutController = new AbortController()
const timeoutId = setTimeout(() => timeoutController.abort(), timeout)

try {
// Send request information off to Kasada for classification
const response = await fetch(kasadaAPIURL, {
method: 'POST',
headers: {
'X-Forwarded-Host': url.hostname,
'Content-Type': 'application/json',
Authorization: `KasadaApiTokenV1 ${process.env.KASADA_TOKEN ?? ''}`
},
signal: timeoutController.signal,
body: JSON.stringify(kasadaPayload),
keepalive: true
})
const metadata = (await response.json()) as APIResponse

return {
metadata
}
} catch (error) {
if (timeoutController.signal.aborted) {
return {
error: new Error('Fetch request timed out')
}
}

// Some other error occurred
return {
error: error instanceof Error ? error : new Error(String(error))
}
} finally {
clearTimeout(timeoutId)
}
}

/**
* Function that continues the request to the origin
*/
async function callOrigin(): Promise<Response> {
return NextResponse.next()
}

/**
* Function that adds the `responseHeadersToSet` headers returned as part of the request metadata
* to the response. These headers are necessary for the correct working of the client side SDK.
*/
function addKasadaHeaders(metadata: APIResponse, response: Response): void {
metadata.responseHeadersToSet.forEach(({ key, value }) => {
response.headers.set(key, value)
})
}

/**
* Function that adds the required CORS headers to the response on an OPTIONS request
*/
function addKasadaCORSHeaders(response: Response): void {
const kasadaHeaders = [
'x-kpsdk-ct',
'x-kpsdk-cd',
'x-kpsdk-h',
'x-kpsdk-fc',
'x-kpsdk-v',
'x-kpsdk-r'
].join(', ')

response.headers.append('access-control-allow-headers', kasadaHeaders)
}

export async function kasadaHandler(
request: NextRequest,
ev: NextFetchEvent
): Promise<Response> {
// If the request is an OPTIONS request we don't send it to Kasada
// but we do add the necessary CORS headers.
if (request.method === 'OPTIONS') {
const response = await callOrigin()
addKasadaCORSHeaders(response)
return response
}

// Get the classification and associated Kasada metadata about this request
const { error, metadata } = await getKasadaMetadata(request)
if (error || metadata === undefined || metadata.error) {
console.error('Kasada error', error || metadata?.error)

return callOrigin()
}

if (metadata.classification !== 'ALLOWED') {
console.info('Kasada metadata bot', metadata.classification, metadata)
} else {
console.log('Kasada metadata', metadata.classification, metadata)
}

// If the request is a Bad Bot and we're in Protect mode, we'll block this request
// and add the Kasada headers to the response for the Client-side SDKs
if (
metadata.classification === 'BAD-BOT' &&
metadata.application.mode === 'PROTECT'
) {
ev.waitUntil(
track('kasada-blocked', {
classification: metadata.classification,
mode: metadata.application.mode,
ip: request.ip || 'unknown'
})
)
const blockResponse = new Response(undefined, {
status: 429
})

addKasadaHeaders(metadata, blockResponse)
return blockResponse
}

if (metadata.classification === 'GOOD-BOT') {
try {
const body = await request.json()
ev.waitUntil(
track('kasada-good-bot', {
classification: metadata.classification,
userAgent: request.headers.get('user-agent') || 'unknown',
ip: request.ip || 'unknown',
model: body.model || 'unknown',
prompt: body.messages?.[0]?.content || 'unknown'
})
)
} catch (e) {
console.error('Error tracking good bot', e)
}
}

// No Bad Bot detected (or application is not in Protect mode)
// let's send the request to the Origin and add Kasada headers to response
const response = await callOrigin()
addKasadaHeaders(metadata, response)
return response
}
Loading

0 comments on commit 3965598

Please sign in to comment.