diff --git a/README.md b/README.md index 90ae44f..af468ca 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ If you want to learn more about this project or have any questions, send us an e - [Supabase](https://supabase.com) - [inngest](https://www.inngest.com) - [Postmark](https://postmarkapp.com) +- [Arcjet](https://arcjet.com) ## Getting Started 🚀 diff --git a/apps/page/README.md b/apps/page/README.md index db6c72c..8fa6d66 100644 --- a/apps/page/README.md +++ b/apps/page/README.md @@ -16,4 +16,7 @@ SUPABASE_SERVICE_ROLE_KEY= # Inngest INNGEST_EVENT_KEY= INNGEST_SIGNING_KEY= + +# Arcjet +ARCJET_KEY= ``` diff --git a/apps/web/README.md b/apps/web/README.md index c708b56..22d5005 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -40,10 +40,6 @@ POSTMARK_WEBHOOK_KEY= INNGEST_EVENT_KEY= INNGEST_SIGNING_KEY= -# Redis -UPSTASH_REDIS_REST_URL= -UPSTASH_REDIS_REST_TOKEN= - # CMS NEXT_PUBLIC_SANITY_PROJECT_ID= diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index a68adf7..a3bd28f 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -3,7 +3,6 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; export async function middleware(req: NextRequest) { - console.log("middleware check", req.nextUrl.pathname); // We need to create a response and hand it to the supabase client to be able to modify the response headers. const res = NextResponse.next(); const supabase = createMiddlewareClient({ req, res }); diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 2cb94c9..bdfd0aa 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -66,6 +66,9 @@ const moduleExports = { "cdn.sanity.io", ], }, + typescript: { + ignoreBuildErrors: true, + }, }; // ensure that your source maps include changes from all other Webpack plugins diff --git a/apps/web/package.json b/apps/web/package.json index c43dda8..6683a0a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "lint": "next lint" }, "dependencies": { + "@arcjet/next": "1.0.0-alpha.20", "@changes-page/supabase": "workspace:*", "@changes-page/ui": "workspace:*", "@changes-page/utils": "workspace:*", @@ -27,7 +28,6 @@ "@supabase/supabase-js": "^2.39.3", "@tailwindcss/typography": "^0.5.1", "@types/canvas-confetti": "^1.6.4", - "@upstash/redis": "^1.20.6", "@vercel/analytics": "^1.0.1", "@vercel/og": "^0.0.20", "canvas-confetti": "^1.9.3", @@ -76,6 +76,6 @@ "postcss": "^8.4.5", "prettier": "^2.3.2", "tailwindcss": "^3.0.15", - "typescript": "^4.9.4" + "typescript": "^4" } } diff --git a/apps/web/pages/api/billing/create-billing-portal.ts b/apps/web/pages/api/billing/create-billing-portal.ts index 9d0959d..5a4ab92 100644 --- a/apps/web/pages/api/billing/create-billing-portal.ts +++ b/apps/web/pages/api/billing/create-billing-portal.ts @@ -1,5 +1,6 @@ -import type { NextApiRequest, NextApiResponse } from "next"; import { IErrorResponse } from "@changes-page/supabase/types/api"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { apiRateLimiter } from "../../../utils/rate-limit"; import { getSupabaseServerClient } from "../../../utils/supabase/supabase-admin"; import { createOrRetrieveCustomer } from "../../../utils/useDatabase"; import { getAppBaseURL } from "./../../../utils/helpers"; @@ -10,6 +11,7 @@ const createBillingSession = async ( res: NextApiResponse<{ url: string } | IErrorResponse> ) => { if (req.method === "POST") { + await apiRateLimiter(req, res); const { return_url } = req.body; try { diff --git a/apps/web/pages/api/billing/redirect-to-checkout.ts b/apps/web/pages/api/billing/redirect-to-checkout.ts index 5fdce1f..5e63382 100644 --- a/apps/web/pages/api/billing/redirect-to-checkout.ts +++ b/apps/web/pages/api/billing/redirect-to-checkout.ts @@ -1,5 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getAppBaseURL } from "../../../utils/helpers"; +import { apiRateLimiter } from "../../../utils/rate-limit"; import { getSupabaseServerClient } from "../../../utils/supabase/supabase-admin"; import { createOrRetrieveCustomer, @@ -13,6 +14,7 @@ const redirectToCheckout = async ( res: NextApiResponse ) => { if (req.method === "GET") { + await apiRateLimiter(req, res); const { return_url } = req.query; try { diff --git a/apps/web/pages/api/emails/subscribers/export-csv.ts b/apps/web/pages/api/emails/subscribers/export-csv.ts index 798f501..e2db8d2 100644 --- a/apps/web/pages/api/emails/subscribers/export-csv.ts +++ b/apps/web/pages/api/emails/subscribers/export-csv.ts @@ -1,6 +1,7 @@ import { supabaseAdmin } from "@changes-page/supabase/admin"; import { Parser } from "@json2csv/plainjs"; import { NextApiRequest, NextApiResponse } from "next"; +import { apiRateLimiter } from "../../../../utils/rate-limit"; import { getSupabaseServerClient } from "../../../../utils/supabase/supabase-admin"; const getSubscribersExportCsv = async ( @@ -9,6 +10,7 @@ const getSubscribersExportCsv = async ( ) => { if (req.method === "GET") { try { + await apiRateLimiter(req, res); const { user } = await getSupabaseServerClient({ req, res }); const { page_id } = req.query; diff --git a/apps/web/pages/api/pages/settings/add-domain.ts b/apps/web/pages/api/pages/settings/add-domain.ts index 7cce2cd..6cc9606 100644 --- a/apps/web/pages/api/pages/settings/add-domain.ts +++ b/apps/web/pages/api/pages/settings/add-domain.ts @@ -1,7 +1,9 @@ import { NextApiRequest, NextApiResponse } from "next"; +import { apiRateLimiter } from "../../../../utils/rate-limit"; import { getSupabaseServerClient } from "../../../../utils/supabase/supabase-admin"; async function addDomain(req: NextApiRequest, res: NextApiResponse) { + await apiRateLimiter(req, res); const { user } = await getSupabaseServerClient({ req, res }); const { domain } = req.body; diff --git a/apps/web/pages/api/pages/settings/check-domain.ts b/apps/web/pages/api/pages/settings/check-domain.ts index 0da8b74..18db62f 100644 --- a/apps/web/pages/api/pages/settings/check-domain.ts +++ b/apps/web/pages/api/pages/settings/check-domain.ts @@ -1,7 +1,9 @@ import { NextApiRequest, NextApiResponse } from "next"; +import { apiRateLimiter } from "../../../../utils/rate-limit"; import { getSupabaseServerClient } from "../../../../utils/supabase/supabase-admin"; async function checkDomain(req: NextApiRequest, res: NextApiResponse) { + await apiRateLimiter(req, res); const { user } = await getSupabaseServerClient({ req, res }); const { domain } = req.query; diff --git a/apps/web/pages/api/pages/validate-url.ts b/apps/web/pages/api/pages/validate-url.ts index f8c27d9..ac91cb8 100644 --- a/apps/web/pages/api/pages/validate-url.ts +++ b/apps/web/pages/api/pages/validate-url.ts @@ -1,6 +1,7 @@ -import type { NextApiRequest, NextApiResponse } from "next"; import { IErrorResponse } from "@changes-page/supabase/types/api"; import { URL_SLUG_REGEX } from "@changes-page/supabase/types/page"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { apiRateLimiter } from "../../../utils/rate-limit"; import { validatePageByUrl } from "../../../utils/useDatabase"; const BLACKLIST = [ @@ -74,6 +75,8 @@ const validatePageUrl = async ( res: NextApiResponse<{ status: boolean } | IErrorResponse> ) => { if (req.method === "POST") { + await apiRateLimiter(req, res); + const { url_slug, page_id } = req.body; console.log("validatePageUrl", { url_slug, page_id }); diff --git a/apps/web/utils/rate-limit.ts b/apps/web/utils/rate-limit.ts index a643a0b..6a31a66 100644 --- a/apps/web/utils/rate-limit.ts +++ b/apps/web/utils/rate-limit.ts @@ -1,45 +1,25 @@ +import arcjet, { slidingWindow } from "@arcjet/next"; import { NextApiRequest, NextApiResponse } from "next"; -import { redis } from "./redis"; -const updateRateLimit = async ( - ip: string = "local", - limit: number = 5, - duration: number = 60 -): Promise<{ limit: number; remaining: number; success: boolean }> => { - const key = `rate_limit:${ip}`; - - let currentCount = await redis.get(key); - - let count = parseInt(currentCount as string, 10) || 0; - - if (count >= limit) { - return { limit, remaining: limit - count, success: false }; - } - - await redis.incr(key); - await redis.expire(key, duration); - - return { limit, remaining: limit - (count + 1), success: true }; -}; - -export async function rateLimiter(req: Request) { - const identifier = req.headers.get("x-forwarded-for") ?? "local"; - const result = await updateRateLimit(identifier); - - return result.success; -} +const aj = arcjet({ + key: process.env.ARCJET_KEY, + characteristics: ["ip.src"], // track requests by IP address + rules: [ + slidingWindow({ + mode: "LIVE", + interval: 60, + max: 20, + }), + ], +}); export async function apiRateLimiter( req: NextApiRequest, res: NextApiResponse ) { - const identifier = (req.headers["x-forwarded-for"] as string) ?? "local"; - const result = await updateRateLimit(identifier); - - res.setHeader("X-RateLimit-Limit", result.limit); - res.setHeader("X-RateLimit-Remaining", result.remaining); + const decision = await aj.protect(req); - if (!result.success) { + if (decision.isDenied()) { return res.status(429).json({ error: { statusCode: 429, diff --git a/apps/web/utils/redis/index.ts b/apps/web/utils/redis/index.ts deleted file mode 100644 index 284c9d0..0000000 --- a/apps/web/utils/redis/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Redis } from "@upstash/redis"; - -export const redis = new Redis({ - url: process.env.UPSTASH_REDIS_REST_URL!, - token: process.env.UPSTASH_REDIS_REST_TOKEN!, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff6c794..bab40ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,9 @@ importers: apps/web: dependencies: + '@arcjet/next': + specifier: 1.0.0-alpha.20 + version: 1.0.0-alpha.20(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.4.0)(next@14.1.0) '@changes-page/supabase': specifier: workspace:* version: link:../../packages/supabase @@ -209,9 +212,6 @@ importers: '@types/canvas-confetti': specifier: ^1.6.4 version: 1.6.4 - '@upstash/redis': - specifier: ^1.20.6 - version: 1.28.2 '@vercel/analytics': specifier: ^1.0.1 version: 1.1.2 @@ -352,7 +352,7 @@ importers: specifier: ^3.0.15 version: 3.4.1 typescript: - specifier: ^4.9.4 + specifier: ^4 version: 4.9.5 packages/supabase: @@ -1647,12 +1647,6 @@ packages: '@typescript-eslint/types': 6.19.1 eslint-visitor-keys: 3.4.3 - /@upstash/redis@1.28.2: - resolution: {integrity: sha512-e4HmmCji6kE6UnriU92sAVGLQcRYAB4fXSPXls08cjW7zdE/wFaDfhAGi1/T4/wUN50bAy/lpHwW4bMkI2EAcw==} - dependencies: - crypto-js: 4.2.0 - dev: false - /@vercel/analytics@1.1.2: resolution: {integrity: sha512-CodhkLCQ/EHzjX8k+Qg+OzTBY0UadykrcfolfSOJVZZY/ZJM5nbhztm9KdbYvMfqKlasAr1+OYy0ThZnDA/MYA==} dependencies: @@ -2212,10 +2206,6 @@ packages: shebang-command: 2.0.0 which: 2.0.2 - /crypto-js@4.2.0: - resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} - dev: false - /css-b64-images@0.2.5: resolution: {integrity: sha512-TgQBEdP07adhrDfXvI5o6bHGukKBNMzp2Ngckc/6d09zpjD2gc1Hl3Ca1CKgb8FXjHi88+Phv2Uegs2kTL4zjg==} hasBin: true