diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index cd1169785b..3a0c7b55eb 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -1,4 +1,5 @@ -import { geolocation } from "@vercel/functions"; +import { checkBotId } from "botid/server"; +import { geolocation, ipAddress } from "@vercel/functions"; import { convertToModelMessages, createUIMessageStream, @@ -31,6 +32,7 @@ import { } from "@/lib/db/queries"; import type { DBMessage } from "@/lib/db/schema"; import { ChatbotError } from "@/lib/errors"; +import { checkIpRateLimit } from "@/lib/ratelimit"; import type { ChatMessage } from "@/lib/types"; import { convertToUIMessages, generateUUID } from "@/lib/utils"; import { generateTitleFromUserMessage } from "../../actions"; @@ -62,12 +64,18 @@ export async function POST(request: Request) { const { id, message, messages, selectedChatModel, selectedVisibilityType } = requestBody; - const session = await auth(); + const [botResult, session] = await Promise.all([checkBotId(), auth()]); + + if (botResult.isBot) { + return new ChatbotError("unauthorized:chat").toResponse(); + } if (!session?.user) { return new ChatbotError("unauthorized:chat").toResponse(); } + await checkIpRateLimit(ipAddress(request)); + const userType: UserType = session.user.type; const messageCount = await getMessageCountByUserId({ diff --git a/instrumentation-client.ts b/instrumentation-client.ts new file mode 100644 index 0000000000..d3ed55a747 --- /dev/null +++ b/instrumentation-client.ts @@ -0,0 +1,10 @@ +import { initBotId } from "botid/client/core"; + +initBotId({ + protect: [ + { + path: "/api/chat", + method: "POST", + }, + ], +}); diff --git a/lib/ai/entitlements.ts b/lib/ai/entitlements.ts index 3bb4e32f0a..6afbeebc54 100644 --- a/lib/ai/entitlements.ts +++ b/lib/ai/entitlements.ts @@ -9,14 +9,14 @@ export const entitlementsByUserType: Record = { * For users without an account */ guest: { - maxMessagesPerDay: 20, + maxMessagesPerDay: 10, }, /* * For users with an account */ regular: { - maxMessagesPerDay: 50, + maxMessagesPerDay: 10, }, /* diff --git a/lib/ratelimit.ts b/lib/ratelimit.ts new file mode 100644 index 0000000000..cc75ab781d --- /dev/null +++ b/lib/ratelimit.ts @@ -0,0 +1,42 @@ +import { createClient } from "redis"; + +import { isProductionEnvironment } from "@/lib/constants"; +import { ChatbotError } from "@/lib/errors"; + +const MAX_MESSAGES_PER_DAY = 10; +const TTL_SECONDS = 60 * 60 * 24; + +let client: ReturnType | null = null; + +function getClient() { + if (!client && process.env.REDIS_URL) { + client = createClient({ url: process.env.REDIS_URL }); + client.on("error", () => {}); + client.connect().catch(() => { + client = null; + }); + } + return client; +} + +export async function checkIpRateLimit(ip: string | undefined) { + if (!isProductionEnvironment || !ip) return; + + const redis = getClient(); + if (!redis?.isReady) return; + + try { + const key = `ip-rate-limit:${ip}`; + const [count] = await redis + .multi() + .incr(key) + .expire(key, TTL_SECONDS, "NX") + .exec(); + + if (typeof count === "number" && count > MAX_MESSAGES_PER_DAY) { + throw new ChatbotError("rate_limit:chat"); + } + } catch (error) { + if (error instanceof ChatbotError) throw error; + } +} diff --git a/next.config.ts b/next.config.ts index d9cbe4556b..41196c8728 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import { withBotId } from "botid/next/config"; const basePath = "/demo"; @@ -23,4 +24,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withBotId(nextConfig); diff --git a/package.json b/package.json index 5cccc5ffbf..ca66ed5a1e 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@xyflow/react": "^12.10.0", "ai": "6.0.37", "bcrypt-ts": "^5.0.2", + "botid": "1.5.6", "class-variance-authority": "^0.7.1", "classnames": "^2.5.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec703b5cd7..ed5cba28c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: bcrypt-ts: specifier: ^5.0.2 version: 5.0.3 + botid: + specifier: 1.5.6 + version: 1.5.6(next@16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.51.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1))(react@19.0.1) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2460,6 +2463,7 @@ packages: '@vercel/postgres@0.10.0': resolution: {integrity: sha512-fSD23DxGND40IzSkXjcFcxr53t3Tiym59Is0jSYIFpG4/0f0KO9SGtcp1sXiebvPaGe7N/tU05cH4yt2S6/IPg==} engines: {node: '>=18.14'} + deprecated: '@vercel/postgres is deprecated. If you are setting up a new database, you can choose an alternate storage solution from the Vercel Marketplace. If you had an existing Vercel Postgres database, it should have been migrated to Neon as a native Vercel integration. You can find more details and the guide to migrate to Neon''s SDKs here: https://neon.com/docs/guides/vercel-postgres-transition-guide' '@xyflow/react@12.10.0': resolution: {integrity: sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==} @@ -2503,6 +2507,17 @@ packages: resolution: {integrity: sha512-2FcgD12xPbwCoe5i9/HK0jJ1xA1m+QfC1e6htG9Bl/hNOnLyaFmQSlqLKcfe3QdnoMPKpKEGFCbESBTg+SJNOw==} engines: {node: '>=18'} + botid@1.5.6: + resolution: {integrity: sha512-KElecPjc1z6WJ6sCJMw6CvLo/CQclwlLJdobqmtOjVqvCXZZmaWvNafVDwBk0Kf5ordIaorDa3YkIx2OlGx7pg==} + peerDependencies: + next: '*' + react: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + next: + optional: true + react: + optional: true + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -5975,6 +5990,11 @@ snapshots: bcrypt-ts@5.0.3: {} + botid@1.5.6(next@16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.51.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1))(react@19.0.1): + optionalDependencies: + next: 16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.51.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1) + react: 19.0.1 + buffer-from@1.1.2: {} bufferutil@4.0.9: