mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* refactor: dedupe edge api json response assembly * refactor: expand jsonResponse helper to all edge functions Roll out jsonResponse() from _json-response.js to 16 files (14 handlers + 2 shared helpers), eliminating 55 instances of the new Response(JSON.stringify(...)) boilerplate. Only exception: health.js uses JSON.stringify(body, null, indent) for pretty-print mode, which is incompatible with the helper signature. Replaced local jsonResponse/json() definitions in contact.js, register-interest.js, and cache-purge.js with the shared import.
192 lines
7.2 KiB
JavaScript
192 lines
7.2 KiB
JavaScript
export const config = { runtime: 'edge' };
|
|
|
|
import { ConvexHttpClient } from 'convex/browser';
|
|
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
|
import { getClientIp, verifyTurnstile } from './_turnstile.js';
|
|
import { jsonResponse } from './_json-response.js';
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
const PHONE_RE = /^[+(]?\d[\d\s()./-]{4,23}\d$/;
|
|
const MAX_FIELD = 500;
|
|
const MAX_MESSAGE = 2000;
|
|
|
|
const FREE_EMAIL_DOMAINS = new Set([
|
|
'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.fr', 'yahoo.co.uk', 'yahoo.co.jp',
|
|
'hotmail.com', 'hotmail.fr', 'hotmail.co.uk', 'outlook.com', 'outlook.fr',
|
|
'live.com', 'live.fr', 'msn.com', 'aol.com', 'icloud.com', 'me.com', 'mac.com',
|
|
'protonmail.com', 'proton.me', 'mail.com', 'zoho.com', 'yandex.com', 'yandex.ru',
|
|
'gmx.com', 'gmx.net', 'gmx.de', 'web.de', 'mail.ru', 'inbox.com',
|
|
'fastmail.com', 'tutanota.com', 'tuta.io', 'hey.com',
|
|
'qq.com', '163.com', '126.com', 'sina.com', 'foxmail.com',
|
|
'rediffmail.com', 'ymail.com', 'rocketmail.com',
|
|
'wanadoo.fr', 'free.fr', 'laposte.net', 'orange.fr', 'sfr.fr',
|
|
't-online.de', 'libero.it', 'virgilio.it',
|
|
]);
|
|
|
|
const rateLimitMap = new Map();
|
|
const RATE_LIMIT = 3;
|
|
const RATE_WINDOW_MS = 60 * 60 * 1000;
|
|
|
|
function isRateLimited(ip) {
|
|
const now = Date.now();
|
|
const entry = rateLimitMap.get(ip);
|
|
if (!entry || now - entry.windowStart > RATE_WINDOW_MS) {
|
|
rateLimitMap.set(ip, { windowStart: now, count: 1 });
|
|
return false;
|
|
}
|
|
entry.count += 1;
|
|
return entry.count > RATE_LIMIT;
|
|
}
|
|
|
|
async function sendNotificationEmail(name, email, organization, phone, message) {
|
|
const resendKey = process.env.RESEND_API_KEY;
|
|
if (!resendKey) {
|
|
console.error('[contact] RESEND_API_KEY not set — lead stored in Convex but notification NOT sent');
|
|
return false;
|
|
}
|
|
const notifyEmail = process.env.CONTACT_NOTIFY_EMAIL || 'sales@worldmonitor.app';
|
|
const emailDomain = (email.split('@')[1] || '').toLowerCase();
|
|
try {
|
|
const res = await fetch('https://api.resend.com/emails', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${resendKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
from: 'World Monitor <noreply@worldmonitor.app>',
|
|
to: [notifyEmail],
|
|
subject: `[WM Enterprise] ${sanitizeForSubject(name)} from ${sanitizeForSubject(organization)}`,
|
|
html: `
|
|
<div style="font-family: -apple-system, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
<h2 style="color: #4ade80;">New Enterprise Contact</h2>
|
|
<table style="width: 100%; border-collapse: collapse;">
|
|
<tr><td style="padding: 8px; font-weight: bold; color: #666;">Name</td><td style="padding: 8px;">${escapeHtml(name)}</td></tr>
|
|
<tr><td style="padding: 8px; font-weight: bold; color: #666;">Email</td><td style="padding: 8px;"><a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a></td></tr>
|
|
<tr><td style="padding: 8px; font-weight: bold; color: #666;">Domain</td><td style="padding: 8px;"><a href="https://${escapeHtml(emailDomain)}" target="_blank">${escapeHtml(emailDomain)}</a></td></tr>
|
|
<tr><td style="padding: 8px; font-weight: bold; color: #666;">Company</td><td style="padding: 8px;">${escapeHtml(organization)}</td></tr>
|
|
<tr><td style="padding: 8px; font-weight: bold; color: #666;">Phone</td><td style="padding: 8px;"><a href="tel:${escapeHtml(phone)}">${escapeHtml(phone)}</a></td></tr>
|
|
<tr><td style="padding: 8px; font-weight: bold; color: #666;">Message</td><td style="padding: 8px;">${escapeHtml(message || 'N/A')}</td></tr>
|
|
</table>
|
|
<p style="color: #999; font-size: 12px; margin-top: 24px;">Sent from worldmonitor.app enterprise contact form</p>
|
|
</div>`,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.text();
|
|
console.error(`[contact] Resend ${res.status}:`, body);
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
console.error('[contact] Resend error:', err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function sanitizeForSubject(str, maxLen = 50) {
|
|
return str.replace(/[\r\n\0]/g, '').slice(0, maxLen);
|
|
}
|
|
|
|
export default async function handler(req) {
|
|
if (isDisallowedOrigin(req)) {
|
|
return jsonResponse({ error: 'Origin not allowed' }, 403);
|
|
}
|
|
|
|
const cors = getCorsHeaders(req, 'POST, OPTIONS');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
return new Response(null, { status: 204, headers: cors });
|
|
}
|
|
|
|
if (req.method !== 'POST') {
|
|
return jsonResponse({ error: 'Method not allowed' }, 405, cors);
|
|
}
|
|
|
|
const ip = getClientIp(req);
|
|
|
|
if (isRateLimited(ip)) {
|
|
return jsonResponse({ error: 'Too many requests' }, 429, cors);
|
|
}
|
|
|
|
let body;
|
|
try {
|
|
body = await req.json();
|
|
} catch {
|
|
return jsonResponse({ error: 'Invalid JSON' }, 400, cors);
|
|
}
|
|
|
|
if (body.website) {
|
|
return jsonResponse({ status: 'sent' }, 200, cors);
|
|
}
|
|
|
|
const turnstileOk = await verifyTurnstile({
|
|
token: body.turnstileToken || '',
|
|
ip,
|
|
logPrefix: '[contact]',
|
|
missingSecretPolicy: 'allow-in-development',
|
|
});
|
|
if (!turnstileOk) {
|
|
return jsonResponse({ error: 'Bot verification failed' }, 403, cors);
|
|
}
|
|
|
|
const { email, name, organization, phone, message, source } = body;
|
|
|
|
if (!email || typeof email !== 'string' || !EMAIL_RE.test(email)) {
|
|
return jsonResponse({ error: 'Invalid email' }, 400, cors);
|
|
}
|
|
|
|
const emailDomain = email.split('@')[1]?.toLowerCase();
|
|
if (emailDomain && FREE_EMAIL_DOMAINS.has(emailDomain)) {
|
|
return jsonResponse({ error: 'Please use your work email address' }, 422, cors);
|
|
}
|
|
|
|
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
|
return jsonResponse({ error: 'Name is required' }, 400, cors);
|
|
}
|
|
if (!organization || typeof organization !== 'string' || organization.trim().length === 0) {
|
|
return jsonResponse({ error: 'Company is required' }, 400, cors);
|
|
}
|
|
if (!phone || typeof phone !== 'string' || !PHONE_RE.test(phone.trim())) {
|
|
return jsonResponse({ error: 'Valid phone number is required' }, 400, cors);
|
|
}
|
|
|
|
const safeName = name.slice(0, MAX_FIELD);
|
|
const safeOrg = organization.slice(0, MAX_FIELD);
|
|
const safePhone = phone.trim().slice(0, 30);
|
|
const safeMsg = typeof message === 'string' ? message.slice(0, MAX_MESSAGE) : undefined;
|
|
const safeSource = typeof source === 'string' ? source.slice(0, 100) : 'enterprise-contact';
|
|
|
|
const convexUrl = process.env.CONVEX_URL;
|
|
if (!convexUrl) {
|
|
return jsonResponse({ error: 'Service unavailable' }, 503, cors);
|
|
}
|
|
|
|
try {
|
|
const client = new ConvexHttpClient(convexUrl);
|
|
await client.mutation('contactMessages:submit', {
|
|
name: safeName,
|
|
email: email.trim(),
|
|
organization: safeOrg,
|
|
phone: safePhone,
|
|
message: safeMsg,
|
|
source: safeSource,
|
|
});
|
|
|
|
const emailSent = await sendNotificationEmail(safeName, email.trim(), safeOrg, safePhone, safeMsg);
|
|
|
|
return jsonResponse({ status: 'sent', emailSent }, 200, cors);
|
|
} catch (err) {
|
|
console.error('[contact] error:', err);
|
|
return jsonResponse({ error: 'Failed to send message' }, 500, cors);
|
|
}
|
|
}
|