mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 15:31:13 -05:00
Introduce centralized rate limiting by applying defined tiers (STRICT, STANDARD, LENIENT, MODERATE) to high-risk edge functions: - export-user-data (STRICT, 5 req/min) - send-contact-message (STANDARD, 20 req/min) - validate-email-backend (LENIENT, 30 req/min) - admin-delete-user, resend-deletion-code (MODERATE) - additional standard targets identified (request-account-deletion, cancel-account-deletion) as per guidance Implements: - Wrapped handlers with withRateLimit using centralized rateLimiters - Imported from shared rate limiter module - Annotated with comments explaining tier rationale - Updated three initial functions and extended coverage to admin/account management functions - Added documentation guide for rate limiting usage This aligns with the Rate Limiting Guide and centralizes rate limit configuration for consistency.
371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
|
import { corsHeaders } from '../_shared/cors.ts';
|
|
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
|
import { sanitizeError } from '../_shared/errorSanitizer.ts';
|
|
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
|
import { formatEdgeError } from '../_shared/errorFormatter.ts';
|
|
|
|
interface ExportOptions {
|
|
include_reviews: boolean;
|
|
include_lists: boolean;
|
|
include_activity_log: boolean;
|
|
include_preferences: boolean;
|
|
format: 'json';
|
|
}
|
|
|
|
// Apply strict rate limiting (5 req/min) for expensive data export operations
|
|
// This prevents abuse and manages server load from large data exports
|
|
serve(withRateLimit(async (req) => {
|
|
const tracking = startRequest();
|
|
|
|
// Handle CORS preflight requests
|
|
if (req.method === 'OPTIONS') {
|
|
return new Response(null, {
|
|
headers: {
|
|
...corsHeaders,
|
|
'X-Request-ID': tracking.requestId
|
|
}
|
|
});
|
|
}
|
|
|
|
try {
|
|
const supabaseClient = createClient(
|
|
Deno.env.get('SUPABASE_URL') ?? '',
|
|
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
|
{
|
|
global: {
|
|
headers: { Authorization: req.headers.get('Authorization')! },
|
|
},
|
|
}
|
|
);
|
|
|
|
// Get authenticated user
|
|
const {
|
|
data: { user },
|
|
error: authError,
|
|
} = await supabaseClient.auth.getUser();
|
|
|
|
if (authError || !user) {
|
|
const duration = endRequest(tracking);
|
|
edgeLogger.error('Authentication failed', {
|
|
action: 'export_auth',
|
|
requestId: tracking.requestId,
|
|
duration
|
|
});
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Unauthorized',
|
|
success: false,
|
|
requestId: tracking.requestId
|
|
}),
|
|
{
|
|
status: 401,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Content-Type': 'application/json',
|
|
'X-Request-ID': tracking.requestId
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
edgeLogger.info('Processing export request', {
|
|
action: 'export_start',
|
|
requestId: tracking.requestId,
|
|
userId: user.id
|
|
});
|
|
|
|
// Check rate limiting - max 1 export per hour
|
|
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
|
const { data: recentExports, error: rateLimitError } = await supabaseClient
|
|
.from('profile_audit_log')
|
|
.select('created_at')
|
|
.eq('user_id', user.id)
|
|
.eq('action', 'data_exported')
|
|
.gte('created_at', oneHourAgo)
|
|
.limit(1);
|
|
|
|
if (rateLimitError) {
|
|
edgeLogger.error('Rate limit check failed', { action: 'export_rate_limit', requestId: tracking.requestId, error: rateLimitError });
|
|
}
|
|
|
|
if (recentExports && recentExports.length > 0) {
|
|
const duration = endRequest(tracking);
|
|
const nextAvailableAt = new Date(new Date(recentExports[0].created_at).getTime() + 60 * 60 * 1000).toISOString();
|
|
edgeLogger.warn('Rate limit exceeded for export', {
|
|
action: 'export_rate_limit',
|
|
requestId: tracking.requestId,
|
|
userId: user.id,
|
|
duration,
|
|
nextAvailableAt
|
|
});
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: false,
|
|
error: 'Rate limited. You can export your data once per hour.',
|
|
rate_limited: true,
|
|
next_available_at: nextAvailableAt,
|
|
requestId: tracking.requestId
|
|
}),
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Content-Type': 'application/json',
|
|
'X-Request-ID': tracking.requestId
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// Parse export options
|
|
const body = await req.json();
|
|
const options: ExportOptions = {
|
|
include_reviews: body.include_reviews ?? true,
|
|
include_lists: body.include_lists ?? true,
|
|
include_activity_log: body.include_activity_log ?? true,
|
|
include_preferences: body.include_preferences ?? true,
|
|
format: 'json'
|
|
};
|
|
|
|
edgeLogger.info('Export options', {
|
|
action: 'export_options',
|
|
requestId: tracking.requestId,
|
|
userId: user.id
|
|
});
|
|
|
|
// Fetch profile data
|
|
const { data: profile, error: profileError } = await supabaseClient
|
|
.from('profiles')
|
|
.select('username, display_name, bio, preferred_pronouns, personal_location, timezone, preferred_language, theme_preference, privacy_level, ride_count, coaster_count, park_count, review_count, reputation_score, created_at, updated_at')
|
|
.eq('user_id', user.id)
|
|
.single();
|
|
|
|
if (profileError) {
|
|
edgeLogger.error('Profile fetch failed', {
|
|
action: 'export_profile',
|
|
requestId: tracking.requestId,
|
|
userId: user.id
|
|
});
|
|
throw new Error('Failed to fetch profile data');
|
|
}
|
|
|
|
// Fetch statistics
|
|
const { count: photoCount } = await supabaseClient
|
|
.from('photos')
|
|
.select('*', { count: 'exact', head: true })
|
|
.eq('submitted_by', user.id);
|
|
|
|
const { count: listCount } = await supabaseClient
|
|
.from('user_lists')
|
|
.select('*', { count: 'exact', head: true })
|
|
.eq('user_id', user.id);
|
|
|
|
const { count: submissionCount } = await supabaseClient
|
|
.from('content_submissions')
|
|
.select('*', { count: 'exact', head: true })
|
|
.eq('user_id', user.id);
|
|
|
|
const statistics = {
|
|
ride_count: profile.ride_count || 0,
|
|
coaster_count: profile.coaster_count || 0,
|
|
park_count: profile.park_count || 0,
|
|
review_count: profile.review_count || 0,
|
|
reputation_score: profile.reputation_score || 0,
|
|
photo_count: photoCount || 0,
|
|
list_count: listCount || 0,
|
|
submission_count: submissionCount || 0,
|
|
account_created: profile.created_at,
|
|
last_updated: profile.updated_at
|
|
};
|
|
|
|
// Fetch reviews if requested
|
|
let reviews = [];
|
|
if (options.include_reviews) {
|
|
const { data: reviewsData, error: reviewsError } = await supabaseClient
|
|
.from('reviews')
|
|
.select(`
|
|
id,
|
|
rating,
|
|
review_text,
|
|
created_at,
|
|
rides(name),
|
|
parks(name)
|
|
`)
|
|
.eq('user_id', user.id)
|
|
.order('created_at', { ascending: false });
|
|
|
|
if (!reviewsError && reviewsData) {
|
|
reviews = reviewsData.map(r => ({
|
|
id: r.id,
|
|
rating: r.rating,
|
|
review_text: r.review_text,
|
|
ride_name: r.rides?.name,
|
|
park_name: r.parks?.name,
|
|
created_at: r.created_at
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Fetch lists if requested
|
|
let lists = [];
|
|
if (options.include_lists) {
|
|
const { data: listsData, error: listsError } = await supabaseClient
|
|
.from('user_lists')
|
|
.select('id, name, description, is_public, created_at')
|
|
.eq('user_id', user.id)
|
|
.order('created_at', { ascending: false });
|
|
|
|
if (!listsError && listsData) {
|
|
lists = await Promise.all(
|
|
listsData.map(async (list) => {
|
|
const { count } = await supabaseClient
|
|
.from('user_list_items')
|
|
.select('*', { count: 'exact', head: true })
|
|
.eq('list_id', list.id);
|
|
|
|
return {
|
|
id: list.id,
|
|
name: list.name,
|
|
description: list.description,
|
|
is_public: list.is_public,
|
|
item_count: count || 0,
|
|
created_at: list.created_at
|
|
};
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
// Fetch activity log if requested
|
|
let activity_log = [];
|
|
if (options.include_activity_log) {
|
|
const { data: activityData, error: activityError } = await supabaseClient
|
|
.from('profile_audit_log')
|
|
.select('id, action, changes, created_at, changed_by, ip_address_hash, user_agent')
|
|
.eq('user_id', user.id)
|
|
.order('created_at', { ascending: false })
|
|
.limit(100);
|
|
|
|
if (!activityError && activityData) {
|
|
activity_log = activityData;
|
|
}
|
|
}
|
|
|
|
// Fetch preferences if requested
|
|
let preferences = {
|
|
unit_preferences: null,
|
|
accessibility_options: null,
|
|
notification_preferences: null,
|
|
privacy_settings: null
|
|
};
|
|
|
|
if (options.include_preferences) {
|
|
const { data: prefsData } = await supabaseClient
|
|
.from('user_preferences')
|
|
.select('unit_preferences, accessibility_options, notification_preferences, privacy_settings')
|
|
.eq('user_id', user.id)
|
|
.single();
|
|
|
|
if (prefsData) {
|
|
preferences = prefsData;
|
|
}
|
|
}
|
|
|
|
// Build export data structure
|
|
const exportData = {
|
|
export_date: new Date().toISOString(),
|
|
user_id: user.id,
|
|
profile: {
|
|
username: profile.username,
|
|
display_name: profile.display_name,
|
|
bio: profile.bio,
|
|
preferred_pronouns: profile.preferred_pronouns,
|
|
personal_location: profile.personal_location,
|
|
timezone: profile.timezone,
|
|
preferred_language: profile.preferred_language,
|
|
theme_preference: profile.theme_preference,
|
|
privacy_level: profile.privacy_level,
|
|
created_at: profile.created_at,
|
|
updated_at: profile.updated_at
|
|
},
|
|
statistics,
|
|
reviews,
|
|
lists,
|
|
activity_log,
|
|
preferences,
|
|
metadata: {
|
|
export_version: '1.0.0',
|
|
data_retention_info: 'Your data is retained according to our privacy policy. You can request deletion at any time from your account settings.',
|
|
instructions: 'This file contains all your personal data stored in ThrillWiki. You can use this for backup purposes or to migrate to another service. For questions, contact support@thrillwiki.com'
|
|
}
|
|
};
|
|
|
|
// Log the export action
|
|
await supabaseClient.from('profile_audit_log').insert([{
|
|
user_id: user.id,
|
|
changed_by: user.id,
|
|
action: 'data_exported',
|
|
changes: {
|
|
export_options: options,
|
|
timestamp: new Date().toISOString(),
|
|
data_size: JSON.stringify(exportData).length,
|
|
requestId: tracking.requestId
|
|
}
|
|
}]);
|
|
|
|
const duration = endRequest(tracking);
|
|
edgeLogger.info('Export completed successfully', {
|
|
action: 'export_complete',
|
|
requestId: tracking.requestId,
|
|
traceId: tracking.traceId,
|
|
userId: user.id,
|
|
duration,
|
|
dataSize: JSON.stringify(exportData).length
|
|
});
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: true,
|
|
data: exportData,
|
|
requestId: tracking.requestId
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Content-Type': 'application/json',
|
|
'Content-Disposition': `attachment; filename="thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json"`,
|
|
'X-Request-ID': tracking.requestId
|
|
}
|
|
}
|
|
);
|
|
|
|
} catch (error) {
|
|
const duration = endRequest(tracking);
|
|
edgeLogger.error('Export error', {
|
|
action: 'export_error',
|
|
requestId: tracking.requestId,
|
|
duration,
|
|
error: formatEdgeError(error)
|
|
});
|
|
const sanitized = sanitizeError(error, 'export-user-data');
|
|
return new Response(
|
|
JSON.stringify({
|
|
...sanitized,
|
|
success: false,
|
|
requestId: tracking.requestId
|
|
}),
|
|
{
|
|
status: 500,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Content-Type': 'application/json',
|
|
'X-Request-ID': tracking.requestId
|
|
}
|
|
}
|
|
);
|
|
}
|
|
}, rateLimiters.strict, corsHeaders));
|