Files
gpt-engineer-app[bot] de921a5fcf Migrate remaining edge functions to wrapper
Refactor process-expired-bans, detect-location, detect-anomalies, rate-limit-metrics, and collect-metrics to use createEdgeFunction wrapper with standardized error handling, tracing, and reduced boilerplate. Update signatures to receive { supabase, span, requestId } (and user where applicable), replace manual logging with span events, remove per-function boilerplate, and ensure consistent wrapper configuration (cors, auth, rate limits, and tracing).
2025-11-11 20:30:24 +00:00

179 lines
5.4 KiB
TypeScript

import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
import { corsHeaders } from '../_shared/cors.ts';
import { addSpanEvent } from '../_shared/logger.ts';
interface IPLocationResponse {
country: string;
countryCode: string;
measurementSystem: 'metric' | 'imperial';
}
// Simple in-memory rate limiter
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT_WINDOW = 60000; // 1 minute
const MAX_REQUESTS = 10; // 10 requests per minute per IP
const MAX_MAP_SIZE = 10000;
let cleanupFailureCount = 0;
const MAX_CLEANUP_FAILURES = 5;
const CLEANUP_FAILURE_RESET_INTERVAL = 300000; // 5 minutes
function cleanupExpiredEntries() {
try {
const now = Date.now();
for (const [ip, data] of rateLimitMap.entries()) {
if (now > data.resetAt) {
rateLimitMap.delete(ip);
}
}
if (cleanupFailureCount > 0) {
cleanupFailureCount = 0;
}
} catch (error: unknown) {
cleanupFailureCount++;
if (cleanupFailureCount >= MAX_CLEANUP_FAILURES) {
try {
const entriesToClear = Math.floor(rateLimitMap.size * 0.5);
const sortedEntries = Array.from(rateLimitMap.entries())
.sort((a, b) => a[1].resetAt - b[1].resetAt);
for (let i = 0; i < entriesToClear && i < sortedEntries.length; i++) {
rateLimitMap.delete(sortedEntries[i][0]);
}
cleanupFailureCount = 0;
} catch {
rateLimitMap.clear();
cleanupFailureCount = 0;
}
}
}
}
setInterval(() => {
if (cleanupFailureCount > 0) {
cleanupFailureCount = 0;
}
}, CLEANUP_FAILURE_RESET_INTERVAL);
function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } {
const now = Date.now();
const existing = rateLimitMap.get(ip);
if (existing && now <= existing.resetAt) {
if (existing.count >= MAX_REQUESTS) {
const retryAfter = Math.ceil((existing.resetAt - now) / 1000);
return { allowed: false, retryAfter };
}
existing.count++;
return { allowed: true };
}
if (!existing && rateLimitMap.size >= MAX_MAP_SIZE) {
cleanupExpiredEntries();
if (rateLimitMap.size >= MAX_MAP_SIZE) {
try {
const toDelete = Math.floor(MAX_MAP_SIZE * 0.3);
const sortedEntries = Array.from(rateLimitMap.entries())
.sort((a, b) => a[1].resetAt - b[1].resetAt);
for (let i = 0; i < toDelete && i < sortedEntries.length; i++) {
rateLimitMap.delete(sortedEntries[i][0]);
}
} catch {
rateLimitMap.clear();
}
}
}
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
return { allowed: true };
}
setInterval(cleanupExpiredEntries, Math.min(RATE_LIMIT_WINDOW / 2, 30000));
const handler = async (req: Request, { span, requestId }: EdgeFunctionContext) => {
// Get client IP
const forwarded = req.headers.get('x-forwarded-for');
const realIP = req.headers.get('x-real-ip');
const clientIP = forwarded?.split(',')[0] || realIP || '8.8.8.8';
// Check rate limit
const rateLimit = checkRateLimit(clientIP);
if (!rateLimit.allowed) {
addSpanEvent(span, 'rate_limit_exceeded', { clientIP: clientIP.substring(0, 8) + '...' });
return new Response(
JSON.stringify({
error: 'Rate limit exceeded',
message: 'Too many requests. Please try again later.',
retryAfter: rateLimit.retryAfter
}),
{
status: 429,
headers: {
'Retry-After': String(rateLimit.retryAfter || 60)
}
}
);
}
addSpanEvent(span, 'detecting_location', { requestId });
// Use configurable geolocation service
const geoApiUrl = Deno.env.get('GEOLOCATION_API_URL') || 'http://ip-api.com/json';
const geoApiFields = Deno.env.get('GEOLOCATION_API_FIELDS') || 'status,country,countryCode';
let geoResponse;
try {
geoResponse = await fetch(`${geoApiUrl}/${clientIP}?fields=${geoApiFields}`);
} catch (fetchError) {
addSpanEvent(span, 'network_error', { error: fetchError instanceof Error ? fetchError.message : String(fetchError) });
throw new Error('Network error: Unable to reach geolocation service');
}
if (!geoResponse.ok) {
throw new Error(`Geolocation service returned ${geoResponse.status}: ${geoResponse.statusText}`);
}
let geoData;
try {
geoData = await geoResponse.json();
} catch (parseError) {
addSpanEvent(span, 'parse_error', { error: parseError instanceof Error ? parseError.message : String(parseError) });
throw new Error('Invalid response format from geolocation service');
}
if (geoData.status !== 'success') {
throw new Error(`Geolocation failed: ${geoData.message || 'Invalid location data'}`);
}
// Countries that primarily use imperial system
const imperialCountries = ['US', 'LR', 'MM'];
const measurementSystem = imperialCountries.includes(geoData.countryCode) ? 'imperial' : 'metric';
const result: IPLocationResponse = {
country: geoData.country,
countryCode: geoData.countryCode,
measurementSystem
};
addSpanEvent(span, 'location_detected', {
country: result.country,
countryCode: result.countryCode,
measurementSystem: result.measurementSystem
});
return result;
};
serve(createEdgeFunction({
name: 'detect-location',
requireAuth: false,
corsHeaders,
enableTracing: true,
logRequests: true,
}, handler));