mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:31:13 -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]]
|
||||
localPort = 5000
|
||||
externalPort = 80
|
||||
|
||||
[[ports]]
|
||||
localPort = 42463
|
||||
externalPort = 3000
|
||||
|
||||
@@ -11,6 +11,40 @@ interface IPLocationResponse {
|
||||
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) => {
|
||||
// Handle CORS preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
@@ -23,6 +57,26 @@ serve(async (req) => {
|
||||
const realIP = req.headers.get('x-real-ip');
|
||||
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);
|
||||
|
||||
// Use configurable geolocation service with proper error handling
|
||||
|
||||
@@ -30,7 +30,10 @@ serve(async (req) => {
|
||||
const authHeader = req.headers.get('Authorization')
|
||||
if (!authHeader) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Authentication required for delete operations' }),
|
||||
JSON.stringify({
|
||||
error: 'Authentication required',
|
||||
message: 'Authentication required for delete operations'
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -49,7 +52,10 @@ serve(async (req) => {
|
||||
if (authError || !user) {
|
||||
console.error('Auth verification failed:', authError)
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid authentication' }),
|
||||
JSON.stringify({
|
||||
error: 'Invalid authentication',
|
||||
message: 'Authentication token is invalid or expired'
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -67,7 +73,10 @@ serve(async (req) => {
|
||||
if (profileError || !profile) {
|
||||
console.error('Failed to fetch user profile:', profileError)
|
||||
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,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -77,7 +86,10 @@ serve(async (req) => {
|
||||
|
||||
if (profile.banned) {
|
||||
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,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -91,7 +103,10 @@ serve(async (req) => {
|
||||
requestBody = await req.json();
|
||||
} catch (error) {
|
||||
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,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -103,7 +118,10 @@ serve(async (req) => {
|
||||
|
||||
if (!imageId || typeof imageId !== 'string' || imageId.trim() === '') {
|
||||
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,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -125,7 +143,10 @@ serve(async (req) => {
|
||||
} catch (fetchError) {
|
||||
console.error('Network error deleting image:', fetchError)
|
||||
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,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -139,7 +160,10 @@ serve(async (req) => {
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse Cloudflare delete response:', parseError)
|
||||
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,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -151,7 +175,8 @@ serve(async (req) => {
|
||||
console.error('Cloudflare delete error:', deleteResult)
|
||||
return new Response(
|
||||
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
|
||||
}),
|
||||
{
|
||||
@@ -174,7 +199,10 @@ serve(async (req) => {
|
||||
const authHeader = req.headers.get('Authorization')
|
||||
if (!authHeader) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Authentication required for upload operations' }),
|
||||
JSON.stringify({
|
||||
error: 'Authentication required',
|
||||
message: 'Authentication required for upload operations'
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -193,7 +221,10 @@ serve(async (req) => {
|
||||
if (authError || !user) {
|
||||
console.error('Auth verification failed:', authError)
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid authentication' }),
|
||||
JSON.stringify({
|
||||
error: 'Invalid authentication',
|
||||
message: 'Authentication token is invalid or expired'
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -211,7 +242,10 @@ serve(async (req) => {
|
||||
if (profileError || !profile) {
|
||||
console.error('Failed to fetch user profile:', profileError)
|
||||
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,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -221,7 +255,10 @@ serve(async (req) => {
|
||||
|
||||
if (profile.banned) {
|
||||
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,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -240,7 +277,10 @@ serve(async (req) => {
|
||||
// Validate request body structure
|
||||
if (requestBody && typeof requestBody !== 'object') {
|
||||
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,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -274,7 +314,10 @@ serve(async (req) => {
|
||||
} catch (fetchError) {
|
||||
console.error('Network error getting upload URL:', fetchError)
|
||||
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,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -288,7 +331,10 @@ serve(async (req) => {
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse Cloudflare upload response:', parseError)
|
||||
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,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -300,7 +346,8 @@ serve(async (req) => {
|
||||
console.error('Cloudflare direct upload error:', directUploadResult)
|
||||
return new Response(
|
||||
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
|
||||
}),
|
||||
{
|
||||
@@ -328,7 +375,10 @@ serve(async (req) => {
|
||||
const authHeader = req.headers.get('Authorization')
|
||||
if (!authHeader) {
|
||||
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,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -347,7 +397,10 @@ serve(async (req) => {
|
||||
if (authError || !user) {
|
||||
console.error('Auth verification failed:', authError)
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid authentication' }),
|
||||
JSON.stringify({
|
||||
error: 'Invalid authentication',
|
||||
message: 'Authentication token is invalid or expired'
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -361,7 +414,10 @@ serve(async (req) => {
|
||||
|
||||
if (!imageId || imageId.trim() === '') {
|
||||
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,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -382,7 +438,10 @@ serve(async (req) => {
|
||||
} catch (fetchError) {
|
||||
console.error('Network error fetching image status:', fetchError)
|
||||
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,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -396,7 +455,10 @@ serve(async (req) => {
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse Cloudflare image status response:', parseError)
|
||||
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,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -408,7 +470,8 @@ serve(async (req) => {
|
||||
console.error('Cloudflare image status error:', imageResult)
|
||||
return new Response(
|
||||
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
|
||||
}),
|
||||
{
|
||||
@@ -447,7 +510,10 @@ serve(async (req) => {
|
||||
}
|
||||
|
||||
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,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
@@ -458,8 +524,8 @@ serve(async (req) => {
|
||||
console.error('Upload error:', error)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Internal server error',
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
error: 'Internal server error',
|
||||
message: error instanceof Error ? error.message : 'An unexpected error occurred'
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
|
||||
Reference in New Issue
Block a user