add contact api

This commit is contained in:
2026-01-01 13:01:11 +07:00
parent 3866e8ade0
commit 46fdd77353
10 changed files with 451 additions and 49 deletions

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { rateLimited } from "@/lib/server-utils";
const validateInput = (data: any) => {
return (
typeof data !== "object" ||
(
!data.anon &&
(
typeof data.name !== "string" ||
typeof data.email !== "string"
) ||
typeof data.message !== "string"
) ||
!data.anon &&
(
!data.name.trim() ||
!data.email.trim()
) ||
!data.message.trim()
)
}
export async function POST(req: NextRequest) {
const agent = req.headers.get("x-forwarded-for") ?? "damn";
const isRateLimited = await rateLimited(agent);
if (isRateLimited) {
return NextResponse.json(
{ error: "Too many requests" },
{ status: 429 }
);
}
let data;
try {
data = await req.json();
} catch {
return NextResponse.json(
{ error: "Invalid input" },
{ status: 400 }
);
}
if (validateInput(data)) {
return NextResponse.json(
{ error: "Invalid input" },
{ status: 400 }
);
}
return NextResponse.json({ status: "ok" });
}

View File

@@ -1,7 +0,0 @@
import { NextResponse } from "next/server";
export function GET (req: Request) {
console.log(req.headers.get("x-forwarded-for") ?? 'damn');
return NextResponse.json({ status: "ok" });
}

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Roboto_Mono } from 'next/font/google'
import "./globals.css";
import Script from "next/script";
const roboto_mono = Roboto_Mono({
subsets: ['latin']
@@ -22,6 +23,7 @@ export default function RootLayout({
className={`bg-background ${roboto_mono.className} antialiased text-white`}
>
{children}
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer />
</body>
</html>
);

View File

@@ -1,6 +1,8 @@
'use client'
import { ChangeEvent, useRef, useState } from "react";
import { useRef, useState } from "react";
import axios from "axios";
import { CheckboxInput, Input, Submit } from "./form";
import { FloatingLabel } from "../floating-label";
@@ -33,8 +35,8 @@ export default function ContactForm() {
message: formData.get('message')!.toString()
}
if ((data.name && data.email) && (data.name.length < 1 || data.email.length < 1) && !data.anon) {
setErrorMsg("Name and email are required. If you prefer not to provide them, check the box to send anonymously.");
if ((!data.name || !data.email) && !data.anon) {
setErrorMsg("Name and email are required. Unless you check the box to send anonymously");
return;
}
@@ -44,42 +46,48 @@ export default function ContactForm() {
}
setStatus('loading');
//process
await new Promise(res => setTimeout(res, 2000));
setStatus('success');
try {
const req = await axios.post('/api/contact', data);
const res = req.data;
console.log(res);
setStatus('success');
}
catch (err) {
console.error(err);
setStatus('failed');
setErrorMsg("Something went wrong, Sorry...");
}
}
return (
<form action="POST" className="space-y-4" ref={formRef} onSubmit={send}>
<FloatingLabel placeholder="Leave an anonymous message, but I won't answer it">
<FloatingLabel placeholder="Leave an anonymous message, but I can't reply to you">
<CheckboxInput
name="anon"
placeholder="Send anonymously🤫"
onChange={setAnon}
/>
</FloatingLabel>
{!anon && <>
<label className="block" htmlFor="contact-name">
<div className="mb-2">Name</div>
<Input
id="contact-name"
className="w-full"
name="name"
type="text"
placeholder="Any name you want me to know"
/>
</label>
<label className="block" htmlFor="contact-email">
<div className="mb-2">Email</div>
<Input
id="contact-email"
className="w-full"
name="email"
type="email"
/>
</label>
</>}
<label className={anon ? "hidden" : "block"} htmlFor="contact-name">
<div className="mb-2">Name</div>
<Input
id="contact-name"
className="w-full"
name="name"
type="text"
placeholder="Any name you want me to know"
/>
</label>
<label className={anon ? "hidden" : "block"} htmlFor="contact-email">
<div className="mb-2">Email</div>
<Input
id="contact-email"
className="w-full"
name="email"
type="email"
/>
</label>
<label className="block" htmlFor="contact-msg">
<div className="mb-2">Message</div>
<textarea

View File

@@ -0,0 +1,9 @@
'use client'
export const CloudflareTurnstile = () => (
<div
className="cf-turnstile [&_#content]:bg-background"
data-sitekey={process.env.NEXT_PUBLIC_CF_SITE_KEY}
data-theme="dark"
/>
)

5
src/lib/redis.ts Normal file
View File

@@ -0,0 +1,5 @@
import Redis from "ioredis";
export const redis = new Redis(process.env.REDIS_URL!, {
maxRetriesPerRequest: 3,
});

37
src/lib/server-utils.ts Normal file
View File

@@ -0,0 +1,37 @@
import axios from "axios";
import { redis } from "./redis";
export async function rateLimited(clientId: string) {
const key = `contact:${clientId}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, 600);
}
return count > 5;
}
export async function validateTurnstile(token: string, remoteip: string) {
const body = new URLSearchParams({
secret: process.env.CF_SECRET_KEY!,
token,
remoteip
})
try {
const res = await axios.post("https://challenges.cloudflare.com/turnstile/v0/siteverify", body, {
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
});
const result = await res.data;
console.log(result);
} catch (error) {
console.error("Turnstile validation error:", error);
return { success: false, "error-codes": ["internal-error"] };
}
}
export async function sendEmail(target: string) {
}