mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:31:11 -05:00
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).
179 lines
5.4 KiB
TypeScript
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));
|