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>
150 lines
5.5 KiB
TypeScript
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" } }
|
|
);
|
|
}
|
|
});
|