Add rate limit on contact-us form using redis + move to app router (#4614)
## Problem solved
Short description of the bug fixed or feature added
<!-- start pr-codex -->
---
## PR-Codex overview
The focus of this PR is to implement rate limiting for a HubSpot API endpoint using Redis caching.
### Detailed summary
- Added `cacheGet` and `cacheSet` functions for Redis caching
- Implemented rate limiting logic based on IP address
- Added `rateLimiter` function to handle rate limiting
- Updated handler to use `rateLimiter` before processing requests
> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`
<!-- end pr-codex -->
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import invariant from "tiny-invariant";
|
||||
import { cacheGet, cacheSet } from "../../../lib/redis";
|
||||
import type { ContactFormPayload } from "./types";
|
||||
|
||||
// Note: This handler cannot use "edge" runtime because of Redis usage.
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
const rateLimitedResponse = await rateLimiter(req);
|
||||
if (rateLimitedResponse) {
|
||||
return rateLimitedResponse;
|
||||
}
|
||||
|
||||
const requestBody = (await req.json()) as ContactFormPayload;
|
||||
|
||||
const { fields } = requestBody;
|
||||
|
||||
invariant(process.env.HUBSPOT_ACCESS_TOKEN, "missing HUBSPOT_ACCESS_TOKEN");
|
||||
|
||||
const response = await fetch(
|
||||
"https://api.hsforms.com/submissions/v3/integration/secure/submit/23987964/38849262-3605-4eb2-883b-b4f1aa5ad845",
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.HUBSPOT_ACCESS_TOKEN}`,
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({ fields }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json();
|
||||
console.error("error", body);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ status: response.statusText },
|
||||
{
|
||||
status: response.status,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
async function rateLimiter(req: NextRequest) {
|
||||
// Max 1 requests per minute
|
||||
const rateLimitSeconds = 60;
|
||||
|
||||
const ipAddress =
|
||||
req.headers.get("CF-Connecting-IP") ||
|
||||
req.ip ||
|
||||
req.headers.get("X-Forwarded-For");
|
||||
|
||||
if (!ipAddress) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Could not validate elligibility.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = `contact-us:${ipAddress}`;
|
||||
const cacheValue = await cacheGet(cacheKey);
|
||||
|
||||
// if we have a cached value, return an error
|
||||
if (cacheValue !== null) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Rate limit exceeded. Try again in ${rateLimitSeconds} seconds.`,
|
||||
},
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// cache it for `rateLimitSeconds`
|
||||
await cacheSet(cacheKey, "contact-us-used", rateLimitSeconds);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type ContactFormPayload = {
|
||||
fields: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import invariant from "tiny-invariant";
|
||||
|
||||
export const config = {
|
||||
runtime: "edge",
|
||||
};
|
||||
|
||||
interface ContactFormPayload {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
fields: any;
|
||||
}
|
||||
|
||||
const handler = async (req: NextRequest) => {
|
||||
if (req.method !== "POST") {
|
||||
return NextResponse.json({ error: "invalid method" }, { status: 400 });
|
||||
}
|
||||
|
||||
const requestBody = (await req.json()) as ContactFormPayload;
|
||||
|
||||
const { fields } = requestBody;
|
||||
invariant(process.env.HUBSPOT_ACCESS_TOKEN, "missing HUBSPOT_ACCESS_TOKEN");
|
||||
|
||||
const response = await fetch(
|
||||
"https://api.hsforms.com/submissions/v3/integration/secure/submit/23987964/38849262-3605-4eb2-883b-b4f1aa5ad845",
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.HUBSPOT_ACCESS_TOKEN}`,
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({ fields }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json();
|
||||
console.error("error", body);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ status: response.statusText },
|
||||
{
|
||||
status: response.status,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -23,6 +23,7 @@ import { useForm } from "react-hook-form";
|
||||
import { BsFillLightningChargeFill } from "react-icons/bs";
|
||||
import { Button, Card, Heading, Text } from "tw-components";
|
||||
import type { ThirdwebNextPage } from "utils/types";
|
||||
import type { ContactFormPayload } from "../app/api/contact-us/types";
|
||||
|
||||
interface FormSchema {
|
||||
firstname: string;
|
||||
@@ -139,11 +140,12 @@ const ContactUs: ThirdwebNextPage = () => {
|
||||
gap={4}
|
||||
as="form"
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
const fields = Object.keys(data).map((key) => ({
|
||||
name: key,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
value: (data as any)[key],
|
||||
}));
|
||||
const payload: ContactFormPayload = {
|
||||
fields: Object.keys(data).map((key) => ({
|
||||
name: key,
|
||||
value: data[key as keyof FormSchema],
|
||||
})),
|
||||
};
|
||||
|
||||
setFormStatus("submitting");
|
||||
|
||||
@@ -154,9 +156,9 @@ const ContactUs: ThirdwebNextPage = () => {
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/hubspot", {
|
||||
const response = await fetch("/api/contact-us", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ fields }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
Reference in New Issue
Block a user