link-stack/apps/link/app/api/formstack/route.ts
2025-11-10 14:55:22 +01:00

98 lines
3 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { createLogger } from "@link-stack/logger";
import { getWorkerUtils } from "@link-stack/bridge-common";
import { timingSafeEqual } from "crypto";
// Force this route to be dynamic (not statically generated at build time)
export const dynamic = 'force-dynamic';
const logger = createLogger('formstack-webhook');
export async function POST(req: NextRequest): Promise<NextResponse> {
try {
const clientIp = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || 'unknown';
// Get the shared secret from environment variable
const expectedSecret = process.env.FORMSTACK_SHARED_SECRET;
if (!expectedSecret) {
logger.error('FORMSTACK_SHARED_SECRET environment variable is not configured');
return NextResponse.json(
{ error: "Server configuration error" },
{ status: 500 }
);
}
// Get the shared secret from the request body
const body = await req.json();
const receivedSecret = body.HandshakeKey;
// Validate that secret is provided
if (!receivedSecret || typeof receivedSecret !== 'string') {
logger.warn({ clientIp }, 'Missing or invalid HandshakeKey');
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Use timing-safe comparison to prevent timing attacks
const expectedBuffer = Buffer.from(expectedSecret);
const receivedBuffer = Buffer.from(receivedSecret);
let secretsMatch = false;
if (expectedBuffer.length === receivedBuffer.length) {
try {
secretsMatch = timingSafeEqual(expectedBuffer, receivedBuffer);
} catch (e) {
secretsMatch = false;
}
}
if (!secretsMatch) {
logger.warn({
secretMatch: false,
timestamp: new Date().toISOString(),
userAgent: req.headers.get('user-agent'),
clientIp
}, 'Invalid shared secret received');
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Log webhook receipt with non-PII metadata only
logger.info({
formId: body.FormID,
uniqueId: body.UniqueID,
timestamp: new Date().toISOString(),
fieldCount: Object.keys(body).length
}, 'Received Formstack webhook');
// Enqueue a bridge-worker task to process this form submission
const worker = await getWorkerUtils();
await worker.addJob('formstack/create-ticket-from-form', {
formData: body,
receivedAt: new Date().toISOString(),
});
logger.info('Formstack webhook task enqueued successfully');
return NextResponse.json({
status: "success",
message: "Webhook received and queued for processing"
});
} catch (error) {
logger.error({
error: error instanceof Error ? error.message : error,
stack: error instanceof Error ? error.stack : undefined,
}, 'Error processing Formstack webhook');
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}