diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ae234b..d2f7f93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -353,3 +353,17 @@ All contributors will be recognized in the project. We value every contribution
Built with ❤️ by the FlipTrack community + +--- + +## Rate Limiting + +A reusable `rateLimit(request, limit, windowMs)` utility is available in `app/utils/rate-limit.server.ts`. + +Example: + +```ts +await rateLimit(request, 5, 60_000); +``` + +This implementation currently uses an in-memory store. Because FlipTrack is deployed on Vercel, the in-memory cache is per serverless invocation and is not shared across instances. For production deployments requiring distributed rate limiting, consider using a shared backend such as Upstash Redis or Vercel KV. diff --git a/app/routes/api.insights.ts b/app/routes/api.insights.ts index b387740..8f8d047 100644 --- a/app/routes/api.insights.ts +++ b/app/routes/api.insights.ts @@ -1,5 +1,6 @@ import type { Route } from "./+types/api.insights"; import { getSupabaseServerClient } from "~/utils/supabase.server"; +import { rateLimit } from "~/utils/rate-limit.server"; import { PrismaClient } from "@prisma/client"; import { generateText } from "ai"; import { createGroq } from "@ai-sdk/groq"; @@ -7,6 +8,8 @@ import { createGroq } from "@ai-sdk/groq"; const prisma = new PrismaClient(); export async function action({ request }: Route.ActionArgs) { + await rateLimit(request, 10, 60_000); + const groq = createGroq({ apiKey: process.env.GROQ_API_KEY }); const { supabase } = getSupabaseServerClient(request); const { data: { user } } = await supabase.auth.getUser(); diff --git a/app/routes/login-page.tsx b/app/routes/login-page.tsx index 2abedaa..9c09860 100644 --- a/app/routes/login-page.tsx +++ b/app/routes/login-page.tsx @@ -6,6 +6,7 @@ import { LoginForm } from "~/blocks/login-page/login-form"; import { OAuthOptions } from "~/blocks/login-page/o-auth-options"; import { MagicLinkOption } from "~/blocks/login-page/magic-link-option"; import { SignupLink } from "~/blocks/login-page/signup-link"; +import { rateLimit } from "~/utils/rate-limit.server"; export async function loader({ request }: Route.LoaderArgs) { const { supabase, headers } = getSupabaseServerClient(request); @@ -15,7 +16,10 @@ export async function loader({ request }: Route.LoaderArgs) { } export async function action({ request }: Route.ActionArgs) { + await rateLimit(request, 5, 60_000); + const { supabase, headers } = getSupabaseServerClient(request); + const formData = await request.formData(); const email = formData.get("email") as string; const password = formData.get("password") as string; diff --git a/app/routes/signup-page.tsx b/app/routes/signup-page.tsx index f34dd79..0fadb79 100644 --- a/app/routes/signup-page.tsx +++ b/app/routes/signup-page.tsx @@ -7,6 +7,7 @@ import { SignupForm } from "~/blocks/signup-page/signup-form"; import { OAuthSignup } from "~/blocks/signup-page/o-auth-signup"; import { LoginLink } from "~/blocks/signup-page/login-link"; import { TermsAcceptance } from "~/blocks/signup-page/terms-acceptance"; +import { rateLimit } from "~/utils/rate-limit.server"; const prisma = new PrismaClient(); @@ -18,7 +19,10 @@ export async function loader({ request }: Route.LoaderArgs) { } export async function action({ request }: Route.ActionArgs) { + await rateLimit(request, 5, 60_000); + const { supabase, headers } = getSupabaseServerClient(request); + const formData = await request.formData(); const name = formData.get("name") as string; const email = formData.get("email") as string; diff --git a/app/utils/rate-limit.server.ts b/app/utils/rate-limit.server.ts new file mode 100644 index 0000000..1fbd2f1 --- /dev/null +++ b/app/utils/rate-limit.server.ts @@ -0,0 +1,63 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +export function getClientIp(request: Request): string { + return ( + request.headers.get("x-vercel-forwarded-for") || + request.headers.get("x-real-ip") || + request.headers.get("x-forwarded-for")?.split(",")[0].trim() || + "unknown_ip" + ); +} + +export async function rateLimit( + request: Request, + limit = 5, + windowMs = 60_000 +) { + const ip = getClientIp(request); + const now = new Date(); + + const resetAt = new Date(now.getTime() + windowMs); + + const result = await prisma.rateLimit.upsert({ + where: { ip }, + create: { + ip, + count: 1, + resetAt, + }, + update: { + count: { + increment: 1, + }, + }, + }); + + if (result.resetAt <= now) { + await prisma.rateLimit.update({ + where: { ip }, + data: { + count: 1, + resetAt, + }, + }); + + return; + } + + if (result.count > limit) { + throw new Response( + JSON.stringify({ + error: "Too many attempts. Please try again later.", + }), + { + status: 429, + headers: { + "Content-Type": "application/json", + }, + } + ); + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7753209..dfee3b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -135,6 +135,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -567,29 +568,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -2428,6 +2406,7 @@ "resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.16.0.tgz", "integrity": "sha512-ZI26LhXH65HP6z89+VzTK4XXDibnJczCUNQOgZIabKXSx8bmSzwujHe0brFc/eBW8N+tFBn/OZ2UuqELi9MwBg==", "license": "MIT", + "peer": true, "dependencies": { "@mjackson/node-fetch-server": "^0.2.0", "@react-router/express": "7.16.0", @@ -2865,6 +2844,7 @@ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.107.0.tgz", "integrity": "sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==", "license": "MIT", + "peer": true, "dependencies": { "@supabase/auth-js": "2.107.0", "@supabase/functions-js": "2.107.0", @@ -2982,6 +2962,7 @@ "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2992,6 +2973,7 @@ "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3002,6 +2984,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3267,6 +3250,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3875,6 +3859,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -4270,18 +4255,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/jose": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", @@ -4931,6 +4904,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5009,6 +4983,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -5156,6 +5131,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5165,6 +5141,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5177,6 +5154,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.77.0.tgz", "integrity": "sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -5200,6 +5178,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5280,6 +5259,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz", "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==", "license": "MIT", + "peer": true, "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -5394,7 +5374,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -5786,6 +5767,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5967,6 +5949,7 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -6128,6 +6111,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/prisma/migrations/20260708160733_add_rate_limit_model/migration.sql b/prisma/migrations/20260708160733_add_rate_limit_model/migration.sql new file mode 100644 index 0000000..f035e4f --- /dev/null +++ b/prisma/migrations/20260708160733_add_rate_limit_model/migration.sql @@ -0,0 +1,65 @@ +-- CreateTable +CREATE TABLE "Invite" ( + "id" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" TEXT NOT NULL, + "token" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Invite_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Integration" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "teamId" TEXT, + "platform" TEXT NOT NULL, + "accessToken" TEXT NOT NULL, + "refreshToken" TEXT, + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Integration_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RateLimit" ( + "ip" TEXT NOT NULL, + "count" INTEGER NOT NULL DEFAULT 1, + "resetAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RateLimit_pkey" PRIMARY KEY ("ip") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Invite_token_key" ON "Invite"("token"); + +-- CreateIndex +CREATE INDEX "Invite_teamId_idx" ON "Invite"("teamId"); + +-- CreateIndex +CREATE INDEX "Invite_token_idx" ON "Invite"("token"); + +-- CreateIndex +CREATE INDEX "Integration_userId_idx" ON "Integration"("userId"); + +-- CreateIndex +CREATE INDEX "Integration_teamId_idx" ON "Integration"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Integration_userId_platform_key" ON "Integration"("userId", "platform"); + +-- AddForeignKey +ALTER TABLE "Invite" ADD CONSTRAINT "Invite_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Integration" ADD CONSTRAINT "Integration_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Integration" ADD CONSTRAINT "Integration_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1c71dde..030f6cd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -127,12 +127,12 @@ model User { } model Team { - id String @id @default(uuid()) - name String - ownerId String @unique - createdAt DateTime @default(now()) - members User[] - invites Invite[] + id String @id @default(uuid()) + name String + ownerId String @unique + createdAt DateTime @default(now()) + members User[] + invites Invite[] integrations Integration[] } @@ -279,17 +279,25 @@ model Integration { id String @id @default(uuid()) userId String teamId String? - platform String // e.g., "SHOPIFY", "EBAY" - accessToken String // Encrypted access token or API key - refreshToken String? // Encrypted refresh token (optional) + platform String // e.g., "SHOPIFY", "EBAY" + accessToken String // Encrypted access token or API key + refreshToken String? // Encrypted refresh token (optional) expiresAt DateTime? // Expiration date/time (optional) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) @@unique([userId, platform]) @@index([userId]) @@index([teamId]) } + +model RateLimit { + ip String @id + count Int @default(1) + resetAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +}