React/Vite/shadcn-ui site for Gigafibre ISP. Address qualification via PostgreSQL (5.2M AQ addresses, pg_trgm fuzzy search). No Supabase dependency for address search. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
173 lines
6.7 KiB
TypeScript
173 lines
6.7 KiB
TypeScript
const corsHeaders = {
|
||
"Access-Control-Allow-Origin": "*",
|
||
"Access-Control-Allow-Headers":
|
||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||
};
|
||
|
||
function escapeHtml(str: string): string {
|
||
return str
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
const RATE_LIMIT_MS = 10_000; // 10 seconds between requests per IP+contact
|
||
const IP_RATE_LIMIT_MS = 60_000; // 1 minute window for global IP limit
|
||
const IP_MAX_REQUESTS = 5; // max 5 requests per minute per IP
|
||
const rateLimitMap = new Map<string, number>();
|
||
const ipRequestLog = new Map<string, number[]>();
|
||
|
||
Deno.serve(async (req) => {
|
||
if (req.method === "OPTIONS") {
|
||
return new Response(null, { headers: corsHeaders });
|
||
}
|
||
|
||
try {
|
||
const { address, contact, fiber_available, max_speed } = await req.json();
|
||
|
||
if (!address || !contact) {
|
||
return new Response(
|
||
JSON.stringify({ error: "Missing address or contact" }),
|
||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||
);
|
||
}
|
||
|
||
// Validate inputs
|
||
if (typeof address !== "string" || address.length > 500) {
|
||
return new Response(
|
||
JSON.stringify({ error: "Invalid address" }),
|
||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||
);
|
||
}
|
||
if (typeof contact !== "string" || contact.length > 254) {
|
||
return new Response(
|
||
JSON.stringify({ error: "Invalid contact" }),
|
||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||
);
|
||
}
|
||
|
||
// Server-side contact format validation
|
||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
const phoneRegex = /^\+?[1-9]\d{1,14}$/;
|
||
const isEmailContact = contact.includes("@");
|
||
|
||
if (isEmailContact && !emailRegex.test(contact)) {
|
||
return new Response(
|
||
JSON.stringify({ error: "Invalid email format" }),
|
||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||
);
|
||
}
|
||
if (!isEmailContact && !phoneRegex.test(contact)) {
|
||
return new Response(
|
||
JSON.stringify({ error: "Invalid phone format" }),
|
||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||
);
|
||
}
|
||
|
||
// Server-side rate limiting
|
||
const clientIp = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
||
|
||
// Global per-IP rate limit: max 5 requests per minute
|
||
const now = Date.now();
|
||
const ipLog = ipRequestLog.get(clientIp) || [];
|
||
const recentIpRequests = ipLog.filter(ts => now - ts < IP_RATE_LIMIT_MS);
|
||
if (recentIpRequests.length >= IP_MAX_REQUESTS) {
|
||
return new Response(
|
||
JSON.stringify({ error: "Too many requests. Please wait before trying again." }),
|
||
{ status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||
);
|
||
}
|
||
|
||
// Per IP+contact rate limit: 10s cooldown
|
||
const rateLimitKey = `${clientIp}:${contact}`;
|
||
const lastRequest = rateLimitMap.get(rateLimitKey);
|
||
if (lastRequest && now - lastRequest < RATE_LIMIT_MS) {
|
||
return new Response(
|
||
JSON.stringify({ error: "Too many requests. Please wait before trying again." }),
|
||
{ status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||
);
|
||
}
|
||
rateLimitMap.set(rateLimitKey, now);
|
||
recentIpRequests.push(now);
|
||
ipRequestLog.set(clientIp, recentIpRequests);
|
||
|
||
// Clean old entries periodically
|
||
if (rateLimitMap.size > 1000) {
|
||
for (const [key, ts] of rateLimitMap) {
|
||
if (now - ts > RATE_LIMIT_MS) rateLimitMap.delete(key);
|
||
}
|
||
}
|
||
if (ipRequestLog.size > 500) {
|
||
for (const [key, timestamps] of ipRequestLog) {
|
||
const valid = timestamps.filter(ts => now - ts < IP_RATE_LIMIT_MS);
|
||
if (valid.length === 0) ipRequestLog.delete(key);
|
||
else ipRequestLog.set(key, valid);
|
||
}
|
||
}
|
||
|
||
const safeAddress = escapeHtml(address);
|
||
const safeContact = escapeHtml(contact);
|
||
|
||
const isEmail = contact.includes("@");
|
||
const contactType = isEmail ? "Courriel" : "Mobile";
|
||
const availability = fiber_available ? "✅ Disponible" : "❌ Non disponible";
|
||
const speedText = max_speed && max_speed > 0
|
||
? max_speed >= 1000 ? `${(max_speed / 1000).toFixed(1)} Gbit/s` : `${max_speed} Mbit/s`
|
||
: "N/A";
|
||
|
||
const htmlBody = `
|
||
<h2>Nouvelle demande de disponibilité fibre</h2>
|
||
<table style="border-collapse:collapse;width:100%;max-width:500px;">
|
||
<tr><td style="padding:8px;border:1px solid #ddd;font-weight:bold;">Adresse</td><td style="padding:8px;border:1px solid #ddd;">${safeAddress}</td></tr>
|
||
<tr><td style="padding:8px;border:1px solid #ddd;font-weight:bold;">Disponibilité</td><td style="padding:8px;border:1px solid #ddd;">${availability}</td></tr>
|
||
<tr><td style="padding:8px;border:1px solid #ddd;font-weight:bold;">Vitesse max</td><td style="padding:8px;border:1px solid #ddd;">${speedText}</td></tr>
|
||
<tr><td style="padding:8px;border:1px solid #ddd;font-weight:bold;">${contactType}</td><td style="padding:8px;border:1px solid #ddd;">${safeContact}</td></tr>
|
||
</table>
|
||
`;
|
||
|
||
const MAILJET_API_KEY = Deno.env.get("MAILJET_API_KEY")!;
|
||
const MAILJET_SECRET_KEY = Deno.env.get("MAILJET_SECRET_KEY")!;
|
||
|
||
const response = await fetch("https://api.mailjet.com/v3.1/send", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: "Basic " + btoa(`${MAILJET_API_KEY}:${MAILJET_SECRET_KEY}`),
|
||
},
|
||
body: JSON.stringify({
|
||
Messages: [
|
||
{
|
||
From: { Email: "louis.targo@gmail.com", Name: "Targo - Vérification" },
|
||
To: [{ Email: "support@targo.ca", Name: "Support Targo" }],
|
||
Subject: `Nouvelle demande – ${fiber_available ? "Fibre disponible" : "Fibre non disponible"} – ${safeContact}`,
|
||
HTMLPart: htmlBody,
|
||
},
|
||
],
|
||
}),
|
||
});
|
||
|
||
const result = await response.text();
|
||
|
||
if (!response.ok) {
|
||
console.error("Mailjet error:", result);
|
||
return new Response(
|
||
JSON.stringify({ error: "Email send failed" }),
|
||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||
);
|
||
}
|
||
|
||
return new Response(
|
||
JSON.stringify({ success: true }),
|
||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||
);
|
||
} catch (err) {
|
||
console.error("send-lead-email error:", err);
|
||
return new Response(
|
||
JSON.stringify({ error: "Internal server error" }),
|
||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||
);
|
||
}
|
||
});
|