mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 12:31:26 -05:00
Add rate limiting to location detection and standardize error messages
Introduces rate limiting to the `detect-location` function and refactors error responses in `upload-image` to provide consistent, descriptive messages. Replit-Commit-Author: Agent Replit-Commit-Session-Id: b3d7d4df-59a9-4a9c-971d-175b92dadbfa Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7cdf4e95-3f41-4180-b8e3-8ef56d032c0e/b3d7d4df-59a9-4a9c-971d-175b92dadbfa/1VcvPNb
This commit is contained in:
4
.replit
4
.replit
@@ -33,3 +33,7 @@ outputType = "webview"
|
|||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 5000
|
localPort = 5000
|
||||||
externalPort = 80
|
externalPort = 80
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 42463
|
||||||
|
externalPort = 3000
|
||||||
|
|||||||
@@ -11,6 +11,40 @@ interface IPLocationResponse {
|
|||||||
measurementSystem: 'metric' | 'imperial';
|
measurementSystem: 'metric' | 'imperial';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simple in-memory rate limiter
|
||||||
|
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
const RATE_LIMIT_WINDOW = 60000; // 1 minute in milliseconds
|
||||||
|
const MAX_REQUESTS = 10; // 10 requests per minute per IP
|
||||||
|
|
||||||
|
function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = rateLimitMap.get(ip);
|
||||||
|
|
||||||
|
if (!existing || now > existing.resetAt) {
|
||||||
|
// Create new entry or reset expired entry
|
||||||
|
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.count >= MAX_REQUESTS) {
|
||||||
|
const retryAfter = Math.ceil((existing.resetAt - now) / 1000);
|
||||||
|
return { allowed: false, retryAfter };
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.count++;
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up old entries periodically to prevent memory leak
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [ip, data] of rateLimitMap.entries()) {
|
||||||
|
if (now > data.resetAt) {
|
||||||
|
rateLimitMap.delete(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, RATE_LIMIT_WINDOW);
|
||||||
|
|
||||||
serve(async (req) => {
|
serve(async (req) => {
|
||||||
// Handle CORS preflight requests
|
// Handle CORS preflight requests
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
@@ -23,6 +57,26 @@ serve(async (req) => {
|
|||||||
const realIP = req.headers.get('x-real-ip');
|
const realIP = req.headers.get('x-real-ip');
|
||||||
const clientIP = forwarded?.split(',')[0] || realIP || '8.8.8.8'; // fallback to Google DNS for testing
|
const clientIP = forwarded?.split(',')[0] || realIP || '8.8.8.8'; // fallback to Google DNS for testing
|
||||||
|
|
||||||
|
// Check rate limit
|
||||||
|
const rateLimit = checkRateLimit(clientIP);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Rate limit exceeded',
|
||||||
|
message: 'Too many requests. Please try again later.',
|
||||||
|
retryAfter: rateLimit.retryAfter
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': String(rateLimit.retryAfter || 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Detecting location for IP:', clientIP);
|
console.log('Detecting location for IP:', clientIP);
|
||||||
|
|
||||||
// Use configurable geolocation service with proper error handling
|
// Use configurable geolocation service with proper error handling
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ serve(async (req) => {
|
|||||||
const authHeader = req.headers.get('Authorization')
|
const authHeader = req.headers.get('Authorization')
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Authentication required for delete operations' }),
|
JSON.stringify({
|
||||||
|
error: 'Authentication required',
|
||||||
|
message: 'Authentication required for delete operations'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -49,7 +52,10 @@ serve(async (req) => {
|
|||||||
if (authError || !user) {
|
if (authError || !user) {
|
||||||
console.error('Auth verification failed:', authError)
|
console.error('Auth verification failed:', authError)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Invalid authentication' }),
|
JSON.stringify({
|
||||||
|
error: 'Invalid authentication',
|
||||||
|
message: 'Authentication token is invalid or expired'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -67,7 +73,10 @@ serve(async (req) => {
|
|||||||
if (profileError || !profile) {
|
if (profileError || !profile) {
|
||||||
console.error('Failed to fetch user profile:', profileError)
|
console.error('Failed to fetch user profile:', profileError)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'User profile not found' }),
|
JSON.stringify({
|
||||||
|
error: 'User profile not found',
|
||||||
|
message: 'Unable to verify user profile'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 403,
|
status: 403,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -77,7 +86,10 @@ serve(async (req) => {
|
|||||||
|
|
||||||
if (profile.banned) {
|
if (profile.banned) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Account suspended. Contact support for assistance.' }),
|
JSON.stringify({
|
||||||
|
error: 'Account suspended',
|
||||||
|
message: 'Account suspended. Contact support for assistance.'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 403,
|
status: 403,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -91,7 +103,10 @@ serve(async (req) => {
|
|||||||
requestBody = await req.json();
|
requestBody = await req.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Invalid JSON in request body' }),
|
JSON.stringify({
|
||||||
|
error: 'Invalid JSON',
|
||||||
|
message: 'Request body must be valid JSON'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -103,7 +118,10 @@ serve(async (req) => {
|
|||||||
|
|
||||||
if (!imageId || typeof imageId !== 'string' || imageId.trim() === '') {
|
if (!imageId || typeof imageId !== 'string' || imageId.trim() === '') {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'imageId is required and must be a non-empty string' }),
|
JSON.stringify({
|
||||||
|
error: 'Invalid imageId',
|
||||||
|
message: 'imageId is required and must be a non-empty string'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -125,7 +143,10 @@ serve(async (req) => {
|
|||||||
} catch (fetchError) {
|
} catch (fetchError) {
|
||||||
console.error('Network error deleting image:', fetchError)
|
console.error('Network error deleting image:', fetchError)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Network error: Unable to reach Cloudflare Images API' }),
|
JSON.stringify({
|
||||||
|
error: 'Network error',
|
||||||
|
message: 'Unable to reach Cloudflare Images API'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -139,7 +160,10 @@ serve(async (req) => {
|
|||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.error('Failed to parse Cloudflare delete response:', parseError)
|
console.error('Failed to parse Cloudflare delete response:', parseError)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Invalid response from Cloudflare Images API' }),
|
JSON.stringify({
|
||||||
|
error: 'Invalid response',
|
||||||
|
message: 'Unable to parse response from Cloudflare Images API'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -152,6 +176,7 @@ serve(async (req) => {
|
|||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'Failed to delete image',
|
error: 'Failed to delete image',
|
||||||
|
message: deleteResult.errors?.[0]?.message || deleteResult.error || 'Unknown error occurred',
|
||||||
details: deleteResult.errors || deleteResult.error
|
details: deleteResult.errors || deleteResult.error
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -174,7 +199,10 @@ serve(async (req) => {
|
|||||||
const authHeader = req.headers.get('Authorization')
|
const authHeader = req.headers.get('Authorization')
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Authentication required for upload operations' }),
|
JSON.stringify({
|
||||||
|
error: 'Authentication required',
|
||||||
|
message: 'Authentication required for upload operations'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -193,7 +221,10 @@ serve(async (req) => {
|
|||||||
if (authError || !user) {
|
if (authError || !user) {
|
||||||
console.error('Auth verification failed:', authError)
|
console.error('Auth verification failed:', authError)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Invalid authentication' }),
|
JSON.stringify({
|
||||||
|
error: 'Invalid authentication',
|
||||||
|
message: 'Authentication token is invalid or expired'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -211,7 +242,10 @@ serve(async (req) => {
|
|||||||
if (profileError || !profile) {
|
if (profileError || !profile) {
|
||||||
console.error('Failed to fetch user profile:', profileError)
|
console.error('Failed to fetch user profile:', profileError)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'User profile not found' }),
|
JSON.stringify({
|
||||||
|
error: 'User profile not found',
|
||||||
|
message: 'Unable to verify user profile'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 403,
|
status: 403,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -221,7 +255,10 @@ serve(async (req) => {
|
|||||||
|
|
||||||
if (profile.banned) {
|
if (profile.banned) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Account suspended. Contact support for assistance.' }),
|
JSON.stringify({
|
||||||
|
error: 'Account suspended',
|
||||||
|
message: 'Account suspended. Contact support for assistance.'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 403,
|
status: 403,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -240,7 +277,10 @@ serve(async (req) => {
|
|||||||
// Validate request body structure
|
// Validate request body structure
|
||||||
if (requestBody && typeof requestBody !== 'object') {
|
if (requestBody && typeof requestBody !== 'object') {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Request body must be a valid JSON object' }),
|
JSON.stringify({
|
||||||
|
error: 'Invalid request body',
|
||||||
|
message: 'Request body must be a valid JSON object'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -274,7 +314,10 @@ serve(async (req) => {
|
|||||||
} catch (fetchError) {
|
} catch (fetchError) {
|
||||||
console.error('Network error getting upload URL:', fetchError)
|
console.error('Network error getting upload URL:', fetchError)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Network error: Unable to reach Cloudflare Images API' }),
|
JSON.stringify({
|
||||||
|
error: 'Network error',
|
||||||
|
message: 'Unable to reach Cloudflare Images API'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -288,7 +331,10 @@ serve(async (req) => {
|
|||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.error('Failed to parse Cloudflare upload response:', parseError)
|
console.error('Failed to parse Cloudflare upload response:', parseError)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Invalid response from Cloudflare Images API' }),
|
JSON.stringify({
|
||||||
|
error: 'Invalid response',
|
||||||
|
message: 'Unable to parse response from Cloudflare Images API'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -301,6 +347,7 @@ serve(async (req) => {
|
|||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'Failed to get upload URL',
|
error: 'Failed to get upload URL',
|
||||||
|
message: directUploadResult.errors?.[0]?.message || directUploadResult.error || 'Unable to create upload URL',
|
||||||
details: directUploadResult.errors || directUploadResult.error
|
details: directUploadResult.errors || directUploadResult.error
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -328,7 +375,10 @@ serve(async (req) => {
|
|||||||
const authHeader = req.headers.get('Authorization')
|
const authHeader = req.headers.get('Authorization')
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Authentication required for image status operations' }),
|
JSON.stringify({
|
||||||
|
error: 'Authentication required',
|
||||||
|
message: 'Authentication required for image status operations'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -347,7 +397,10 @@ serve(async (req) => {
|
|||||||
if (authError || !user) {
|
if (authError || !user) {
|
||||||
console.error('Auth verification failed:', authError)
|
console.error('Auth verification failed:', authError)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Invalid authentication' }),
|
JSON.stringify({
|
||||||
|
error: 'Invalid authentication',
|
||||||
|
message: 'Authentication token is invalid or expired'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -361,7 +414,10 @@ serve(async (req) => {
|
|||||||
|
|
||||||
if (!imageId || imageId.trim() === '') {
|
if (!imageId || imageId.trim() === '') {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'id query parameter is required and must be non-empty' }),
|
JSON.stringify({
|
||||||
|
error: 'Missing id parameter',
|
||||||
|
message: 'id query parameter is required and must be non-empty'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -382,7 +438,10 @@ serve(async (req) => {
|
|||||||
} catch (fetchError) {
|
} catch (fetchError) {
|
||||||
console.error('Network error fetching image status:', fetchError)
|
console.error('Network error fetching image status:', fetchError)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Network error: Unable to reach Cloudflare Images API' }),
|
JSON.stringify({
|
||||||
|
error: 'Network error',
|
||||||
|
message: 'Unable to reach Cloudflare Images API'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -396,7 +455,10 @@ serve(async (req) => {
|
|||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.error('Failed to parse Cloudflare image status response:', parseError)
|
console.error('Failed to parse Cloudflare image status response:', parseError)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Invalid response from Cloudflare Images API' }),
|
JSON.stringify({
|
||||||
|
error: 'Invalid response',
|
||||||
|
message: 'Unable to parse response from Cloudflare Images API'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -409,6 +471,7 @@ serve(async (req) => {
|
|||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'Failed to get image status',
|
error: 'Failed to get image status',
|
||||||
|
message: imageResult.errors?.[0]?.message || imageResult.error || 'Unable to retrieve image information',
|
||||||
details: imageResult.errors || imageResult.error
|
details: imageResult.errors || imageResult.error
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -447,7 +510,10 @@ serve(async (req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Method not allowed' }),
|
JSON.stringify({
|
||||||
|
error: 'Method not allowed',
|
||||||
|
message: 'HTTP method not supported for this endpoint'
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
status: 405,
|
status: 405,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -459,7 +525,7 @@ serve(async (req) => {
|
|||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: error instanceof Error ? error.message : 'Unknown error'
|
message: error instanceof Error ? error.message : 'An unexpected error occurred'
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
|
|||||||
Reference in New Issue
Block a user