site-web-targo/supabase/functions/log-search/index.ts
louispaulb 88dc3714a1 Initial deploy: gigafibre.ca website with self-hosted address search
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>
2026-03-27 14:37:50 -04:00

150 lines
5.5 KiB
TypeScript

import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
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",
};
const RATE_LIMIT_MS = 5_000;
const IP_RATE_LIMIT_MS = 60_000;
const IP_MAX_REQUESTS = 15;
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 body = await req.json();
const { search_term, selected_address_id, selected_address_formatted, fiber_available, max_speed, contact_email, contact_phone } = body;
// Validate required field
if (!search_term || typeof search_term !== "string") {
return new Response(
JSON.stringify({ error: "Missing or invalid search_term" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Length validation
if (search_term.length > 500) {
return new Response(
JSON.stringify({ error: "search_term too long" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (selected_address_id && (typeof selected_address_id !== "string" || selected_address_id.length > 100)) {
return new Response(
JSON.stringify({ error: "Invalid selected_address_id" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (selected_address_formatted && (typeof selected_address_formatted !== "string" || selected_address_formatted.length > 500)) {
return new Response(
JSON.stringify({ error: "Invalid selected_address_formatted" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Validate contact fields if provided
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRegex = /^\+?[1-9]\d{1,14}$/;
if (contact_email) {
if (typeof contact_email !== "string" || contact_email.length > 254 || !emailRegex.test(contact_email)) {
return new Response(
JSON.stringify({ error: "Invalid contact_email" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
}
if (contact_phone) {
if (typeof contact_phone !== "string" || contact_phone.length > 20 || !phoneRegex.test(contact_phone)) {
return new Response(
JSON.stringify({ error: "Invalid contact_phone" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
}
// Rate limiting
const clientIp = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
const now = Date.now();
// Global per-IP limit
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" }),
{ status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Per-IP+term cooldown
const rateLimitKey = `${clientIp}:${search_term.substring(0, 50)}`;
const lastRequest = rateLimitMap.get(rateLimitKey);
if (lastRequest && now - lastRequest < RATE_LIMIT_MS) {
return new Response(
JSON.stringify({ error: "Too many requests" }),
{ status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
rateLimitMap.set(rateLimitKey, now);
recentIpRequests.push(now);
ipRequestLog.set(clientIp, recentIpRequests);
// Cleanup
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);
}
}
// Insert using service role
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const { error } = await supabase.from("search_logs").insert({
search_term,
selected_address_id: selected_address_id || null,
selected_address_formatted: selected_address_formatted || null,
fiber_available: typeof fiber_available === "boolean" ? fiber_available : null,
max_speed: typeof max_speed === "number" ? max_speed : null,
contact_email: contact_email || null,
contact_phone: contact_phone || null,
});
if (error) {
console.error("Insert error:", error.message);
return new Response(
JSON.stringify({ error: "Failed to log search" }),
{ 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("log-search error:", err);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});