← back to Patty
app/api/public/signatures/route.ts
210 lines
import { NextRequest, NextResponse } from 'next/server';
import { query, getClient } from '@/lib/db';
/**
* Simple in-memory rate limiter: max 10 signatures per IP per hour.
* Resets on server restart — acceptable for this use case.
*/
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT_MAX = 10;
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const entry = rateLimitMap.get(ip);
if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return true;
}
if (entry.count >= RATE_LIMIT_MAX) {
return false;
}
entry.count++;
return true;
}
// Clean up expired entries every 10 minutes to prevent memory leaks
setInterval(() => {
const now = Date.now();
for (const [key, value] of rateLimitMap.entries()) {
if (now > value.resetAt) {
rateLimitMap.delete(key);
}
}
}, 10 * 60 * 1000);
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const {
petition_id,
signer_name,
signer_email,
signer_zip,
signer_comment,
opted_in_email,
_hp, // honeypot
} = body;
// Honeypot check: if this hidden field has a value, it's a bot
if (_hp) {
// Return success to not reveal the check to bots
return NextResponse.json({ success: true, signatureCount: 0 }, { status: 200 });
}
// Get IP address
const forwarded = request.headers.get('x-forwarded-for');
const ip = forwarded ? forwarded.split(',')[0].trim() : 'unknown';
// Rate limit check
if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: 'Too many signatures from this IP address. Please try again later.' },
{ status: 429 }
);
}
// Validation: required fields
if (!petition_id) {
return NextResponse.json({ error: 'Petition ID is required.' }, { status: 400 });
}
if (!signer_name || typeof signer_name !== 'string' || signer_name.trim().length === 0) {
return NextResponse.json({ error: 'Please enter your name.' }, { status: 400 });
}
if (signer_name.trim().length > 200) {
return NextResponse.json({ error: 'Name is too long.' }, { status: 400 });
}
if (!signer_email || typeof signer_email !== 'string') {
return NextResponse.json({ error: 'Please enter your email address.' }, { status: 400 });
}
// Email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(signer_email.trim())) {
return NextResponse.json({ error: 'Please enter a valid email address.' }, { status: 400 });
}
if (signer_email.trim().length > 320) {
return NextResponse.json({ error: 'Email address is too long.' }, { status: 400 });
}
// Validate optional fields
if (signer_comment && typeof signer_comment === 'string' && signer_comment.length > 1000) {
return NextResponse.json({ error: 'Comment is too long (max 1000 characters).' }, { status: 400 });
}
if (signer_zip && typeof signer_zip === 'string' && signer_zip.length > 10) {
return NextResponse.json({ error: 'ZIP code is too long.' }, { status: 400 });
}
// Check petition exists and is active
const petitionResult = await query(
`SELECT id, status, signature_count FROM petitions WHERE id = $1`,
[petition_id]
);
if (petitionResult.rows.length === 0) {
return NextResponse.json({ error: 'Petition not found.' }, { status: 404 });
}
const petition = petitionResult.rows[0];
if (petition.status !== 'active') {
return NextResponse.json(
{ error: 'This petition is not currently accepting signatures.' },
{ status: 403 }
);
}
// Use a transaction for atomicity
const client = await getClient();
try {
await client.query('BEGIN');
// Insert signature (UNIQUE constraint on petition_id + signer_email handles dedup)
const sigResult = await client.query(
`INSERT INTO signatures (petition_id, signer_email, signer_name, signer_zip, signer_comment, is_public, ip_address, opted_in_email, source)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'public')
ON CONFLICT (petition_id, signer_email) DO NOTHING
RETURNING *`,
[
petition_id,
signer_email.trim().toLowerCase(),
signer_name.trim(),
signer_zip?.trim() || null,
signer_comment?.trim() || null,
true, // public signatures by default
ip,
opted_in_email === true,
]
);
if (sigResult.rows.length === 0) {
// Duplicate — email already signed this petition
await client.query('ROLLBACK');
// Return the current count so the UI can still show it
const countResult = await query(
`SELECT signature_count FROM petitions WHERE id = $1`,
[petition_id]
);
return NextResponse.json(
{
error: 'You have already signed this petition. Thank you for your support!',
signatureCount: countResult.rows[0]?.signature_count || 0,
},
{ status: 409 }
);
}
// Update petition signature_count
const updateResult = await client.query(
`UPDATE petitions SET signature_count = signature_count + 1 WHERE id = $1 RETURNING signature_count`,
[petition_id]
);
const newCount = updateResult.rows[0]?.signature_count || 0;
// If opted in, add to email_subscribers
if (opted_in_email === true) {
await client.query(
`INSERT INTO email_subscribers (email, name, zip_code, source, petition_ids)
VALUES ($1, $2, $3, 'petition', ARRAY[$4::uuid])
ON CONFLICT DO NOTHING`,
[
signer_email.trim().toLowerCase(),
signer_name.trim(),
signer_zip?.trim() || null,
petition_id,
]
);
}
await client.query('COMMIT');
return NextResponse.json(
{ success: true, signatureCount: newCount },
{ status: 201 }
);
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
} catch (err) {
console.error('[api/public/signatures] POST error:', (err as Error).message);
return NextResponse.json(
{ error: 'Something went wrong. Please try again.' },
{ status: 500 }
);
}
}