114 lines
2.8 KiB
TypeScript
114 lines
2.8 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
|
|
import { validateEmail, isDisposableEmail } from '@gridpilot/identity/domain/value-objects/EmailAddress';
|
|
import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
|
|
|
|
const SIGNUP_DEV_STORE = new Map<string, { email: string; createdAt: number; ip: string }>();
|
|
const SIGNUP_KV_HASH_KEY = 'signups:emails';
|
|
|
|
const isDev = !process.env.KV_REST_API_URL;
|
|
|
|
function jsonError(status: number, message: string, extra: Record<string, unknown> = {}) {
|
|
return NextResponse.json(
|
|
{
|
|
error: message,
|
|
...extra,
|
|
},
|
|
{ status },
|
|
);
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
let body: unknown;
|
|
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return jsonError(400, 'Invalid request body');
|
|
}
|
|
|
|
const email =
|
|
typeof body === 'object' && body !== null && 'email' in body
|
|
? (body as { email: unknown }).email
|
|
: undefined;
|
|
|
|
if (typeof email !== 'string' || !email.trim()) {
|
|
return jsonError(400, 'Invalid email address');
|
|
}
|
|
|
|
const validation = validateEmail(email);
|
|
|
|
if (!validation.success) {
|
|
return jsonError(400, validation.error || 'Invalid email address');
|
|
}
|
|
|
|
const normalizedEmail = validation.email;
|
|
|
|
if (isDisposableEmail(normalizedEmail)) {
|
|
return jsonError(400, 'Disposable email addresses are not allowed');
|
|
}
|
|
|
|
const ip = getClientIp(request);
|
|
|
|
try {
|
|
const rateResult = await checkRateLimit(ip);
|
|
|
|
if (!rateResult.allowed) {
|
|
const retryAfterSeconds = Math.max(0, Math.round((rateResult.resetAt - Date.now()) / 1000));
|
|
|
|
return jsonError(429, 'Too many signups, please try again later.', {
|
|
retryAfter: retryAfterSeconds,
|
|
});
|
|
}
|
|
} catch {
|
|
return jsonError(503, 'Temporarily unable to accept signups.');
|
|
}
|
|
|
|
try {
|
|
if (isDev) {
|
|
const existing = SIGNUP_DEV_STORE.get(normalizedEmail);
|
|
|
|
if (existing) {
|
|
return jsonError(409, 'You are already on the list.');
|
|
}
|
|
|
|
SIGNUP_DEV_STORE.set(normalizedEmail, {
|
|
email: normalizedEmail,
|
|
createdAt: Date.now(),
|
|
ip,
|
|
});
|
|
} else {
|
|
const { kv } = await import('@vercel/kv');
|
|
|
|
const existing = await kv.hget<{ email: string; createdAt: number; ip: string }>(
|
|
SIGNUP_KV_HASH_KEY,
|
|
normalizedEmail,
|
|
);
|
|
|
|
if (existing) {
|
|
return jsonError(409, 'You are already on the list.');
|
|
}
|
|
|
|
await kv.hset(SIGNUP_KV_HASH_KEY, {
|
|
[normalizedEmail]: {
|
|
email: normalizedEmail,
|
|
createdAt: Date.now(),
|
|
ip,
|
|
},
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Signup storage error:', error);
|
|
return jsonError(503, 'Temporarily unable to accept signups.');
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{
|
|
ok: true,
|
|
message: 'You are on the grid! We will be in touch soon.',
|
|
},
|
|
{
|
|
status: 201,
|
|
},
|
|
);
|
|
} |