mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:11: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.
345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
import { serve } from "https://deno.land/std@0.190.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 { edgeLogger } from "../_shared/logger.ts";
|
|
import { createErrorResponse } from "../_shared/errorSanitizer.ts";
|
|
import { formatEdgeError } from "../_shared/errorFormatter.ts";
|
|
|
|
interface ContactSubmission {
|
|
name: string;
|
|
email: string;
|
|
subject: string;
|
|
message: string;
|
|
category: 'general' | 'moderation' | 'technical' | 'account' | 'partnership' | 'report' | 'other';
|
|
captchaToken?: string;
|
|
}
|
|
|
|
const handler = async (req: Request): Promise<Response> => {
|
|
// Handle CORS preflight requests
|
|
if (req.method === 'OPTIONS') {
|
|
return new Response(null, { headers: corsHeaders });
|
|
}
|
|
|
|
const requestId = crypto.randomUUID();
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
// Parse request body
|
|
const body: ContactSubmission = await req.json();
|
|
const { name, email, subject, message, category, captchaToken } = body;
|
|
|
|
edgeLogger.info('Contact form submission received', {
|
|
requestId,
|
|
email,
|
|
category
|
|
});
|
|
|
|
// Validate required fields
|
|
if (!name || !email || !subject || !message || !category) {
|
|
return createErrorResponse(
|
|
{ message: 'Missing required fields' },
|
|
400,
|
|
corsHeaders
|
|
);
|
|
}
|
|
|
|
// Validate field lengths
|
|
if (name.length < 2 || name.length > 100) {
|
|
return createErrorResponse(
|
|
{ message: 'Name must be between 2 and 100 characters' },
|
|
400,
|
|
corsHeaders
|
|
);
|
|
}
|
|
|
|
if (subject.length < 5 || subject.length > 200) {
|
|
return createErrorResponse(
|
|
{ message: 'Subject must be between 5 and 200 characters' },
|
|
400,
|
|
corsHeaders
|
|
);
|
|
}
|
|
|
|
if (message.length < 20 || message.length > 2000) {
|
|
return createErrorResponse(
|
|
{ message: 'Message must be between 20 and 2000 characters' },
|
|
400,
|
|
corsHeaders
|
|
);
|
|
}
|
|
|
|
// Validate email format
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(email)) {
|
|
return createErrorResponse(
|
|
{ message: 'Invalid email address' },
|
|
400,
|
|
corsHeaders
|
|
);
|
|
}
|
|
|
|
// Validate category
|
|
const validCategories = ['general', 'moderation', 'technical', 'account', 'partnership', 'report', 'other'];
|
|
if (!validCategories.includes(category)) {
|
|
return createErrorResponse(
|
|
{ message: 'Invalid category' },
|
|
400,
|
|
corsHeaders
|
|
);
|
|
}
|
|
|
|
// Get user agent and create IP hash
|
|
const userAgent = req.headers.get('user-agent') || 'Unknown';
|
|
const clientIP = req.headers.get('x-forwarded-for') || 'Unknown';
|
|
const ipHash = clientIP !== 'Unknown'
|
|
? await crypto.subtle.digest('SHA-256', new TextEncoder().encode(clientIP + 'thrillwiki_salt'))
|
|
.then(buf => Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''))
|
|
: null;
|
|
|
|
// Initialize Supabase client with service role for rate limiting and insertion
|
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
|
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
|
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
|
|
|
// Check rate limiting (max 3 submissions per email per hour)
|
|
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
|
|
|
const { data: recentSubmissions, error: rateLimitError } = await supabase
|
|
.from('contact_submissions')
|
|
.select('id')
|
|
.eq('email', email)
|
|
.gte('created_at', oneHourAgo);
|
|
|
|
if (rateLimitError) {
|
|
edgeLogger.error('Rate limit check failed', { requestId, error: rateLimitError.message });
|
|
} else if (recentSubmissions && recentSubmissions.length >= 3) {
|
|
edgeLogger.warn('Rate limit exceeded', { requestId, email });
|
|
return createErrorResponse(
|
|
{ message: 'Too many submissions. Please wait an hour before submitting again.' },
|
|
429,
|
|
corsHeaders
|
|
);
|
|
}
|
|
|
|
// Get user ID and profile if authenticated
|
|
const authHeader = req.headers.get('Authorization');
|
|
let userId: string | null = null;
|
|
let submitterUsername: string | null = null;
|
|
let submitterReputation: number | null = null;
|
|
let submitterProfileData: Record<string, unknown> | null = null;
|
|
|
|
if (authHeader) {
|
|
const supabaseClient = createClient(
|
|
supabaseUrl,
|
|
Deno.env.get('SUPABASE_ANON_KEY')!,
|
|
{ global: { headers: { Authorization: authHeader } } }
|
|
);
|
|
|
|
const { data: { user } } = await supabaseClient.auth.getUser();
|
|
userId = user?.id || null;
|
|
|
|
// Fetch user profile for enhanced context
|
|
if (userId) {
|
|
const { data: profile } = await supabase
|
|
.from('profiles')
|
|
.select('username, display_name, reputation_score, ride_count, coaster_count, park_count, review_count, created_at, avatar_url')
|
|
.eq('user_id', userId)
|
|
.single();
|
|
|
|
if (profile) {
|
|
submitterUsername = profile.username;
|
|
submitterReputation = profile.reputation_score || 0;
|
|
submitterProfileData = {
|
|
display_name: profile.display_name,
|
|
member_since: profile.created_at,
|
|
stats: {
|
|
rides: profile.ride_count || 0,
|
|
coasters: profile.coaster_count || 0,
|
|
parks: profile.park_count || 0,
|
|
reviews: profile.review_count || 0,
|
|
},
|
|
reputation: profile.reputation_score || 0,
|
|
avatar_url: profile.avatar_url
|
|
};
|
|
|
|
edgeLogger.info('Enhanced submission with user profile', {
|
|
requestId,
|
|
username: submitterUsername,
|
|
reputation: submitterReputation
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Insert contact submission (ticket number auto-generated by trigger)
|
|
const { data: submission, error: insertError } = await supabase
|
|
.from('contact_submissions')
|
|
.insert({
|
|
user_id: userId,
|
|
submitter_username: submitterUsername,
|
|
submitter_reputation: submitterReputation,
|
|
submitter_profile_data: submitterProfileData,
|
|
name: name.trim(),
|
|
email: email.trim().toLowerCase(),
|
|
subject: subject.trim(),
|
|
message: message.trim(),
|
|
category,
|
|
user_agent: userAgent,
|
|
ip_address_hash: ipHash,
|
|
status: 'pending'
|
|
})
|
|
.select('*, ticket_number')
|
|
.single();
|
|
|
|
if (insertError) {
|
|
edgeLogger.error('Failed to insert contact submission', {
|
|
requestId,
|
|
error: insertError.message
|
|
});
|
|
return createErrorResponse(insertError, 500, corsHeaders);
|
|
}
|
|
|
|
edgeLogger.info('Contact submission created successfully', {
|
|
requestId,
|
|
submissionId: submission.id
|
|
});
|
|
|
|
// Send notification email to admin (async, don't wait)
|
|
const adminEmail = Deno.env.get('ADMIN_EMAIL_ADDRESS') || 'admin@thrillwiki.com';
|
|
const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com';
|
|
const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY');
|
|
|
|
const ticketNumber = submission.ticket_number || 'PENDING';
|
|
const messageId = `<${ticketNumber}.${submission.id}@thrillwiki.com>`;
|
|
|
|
// Insert initial message into email thread
|
|
await supabase
|
|
.from('contact_email_threads')
|
|
.insert({
|
|
submission_id: submission.id,
|
|
direction: 'inbound',
|
|
from_email: email.trim().toLowerCase(),
|
|
to_email: adminEmail,
|
|
subject: subject.trim(),
|
|
body_text: message.trim(),
|
|
message_id: messageId,
|
|
metadata: {
|
|
category: category,
|
|
name: name.trim()
|
|
}
|
|
});
|
|
|
|
// Update thread_id with Message-ID format (always, not just when email is sent)
|
|
const threadId = `${ticketNumber}.${submission.id}`;
|
|
await supabase
|
|
.from('contact_submissions')
|
|
.update({ thread_id: threadId })
|
|
.eq('id', submission.id);
|
|
|
|
if (forwardEmailKey) {
|
|
// Send admin notification
|
|
fetch('https://api.forwardemail.net/v1/emails', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
from: fromEmail,
|
|
to: adminEmail,
|
|
subject: `[${ticketNumber}] New Contact - ${category.charAt(0).toUpperCase() + category.slice(1)}`,
|
|
text: `A new contact message has been received:
|
|
|
|
Ticket: ${ticketNumber}
|
|
From: ${name} (${email})
|
|
Category: ${category.charAt(0).toUpperCase() + category.slice(1)}
|
|
Subject: ${subject}
|
|
|
|
Message:
|
|
${message}
|
|
|
|
Reference ID: ${submission.id}
|
|
Submitted: ${new Date(submission.created_at).toLocaleString()}
|
|
|
|
View in admin panel: https://thrillwiki.com/admin/contact`,
|
|
headers: {
|
|
'Message-ID': messageId,
|
|
'X-Ticket-Number': ticketNumber
|
|
}
|
|
}),
|
|
}).catch(err => {
|
|
edgeLogger.error('Failed to send admin notification', { requestId, error: err.message });
|
|
});
|
|
|
|
// Send user confirmation email
|
|
fetch('https://api.forwardemail.net/v1/emails', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
from: fromEmail,
|
|
to: email,
|
|
subject: `[${ticketNumber}] We've received your message - ThrillWiki Support`,
|
|
text: `Hi ${name},
|
|
|
|
Thank you for contacting ThrillWiki! We've received your message and will respond within 24-48 hours.
|
|
|
|
Your Message Details:
|
|
Ticket Number: ${ticketNumber}
|
|
Category: ${category.charAt(0).toUpperCase() + category.slice(1)}
|
|
Subject: ${subject}
|
|
|
|
When replying to this email, please keep the ticket number in the subject line to ensure your response is properly tracked.
|
|
|
|
Our support team will review your message and get back to you as soon as possible.
|
|
|
|
Best regards,
|
|
The ThrillWiki Team`,
|
|
headers: {
|
|
'Message-ID': messageId,
|
|
'X-Ticket-Number': ticketNumber,
|
|
'References': messageId
|
|
}
|
|
}),
|
|
}).catch(err => {
|
|
edgeLogger.error('Failed to send confirmation email', { requestId, error: err.message });
|
|
});
|
|
}
|
|
|
|
const duration = Date.now() - startTime;
|
|
edgeLogger.info('Contact submission processed successfully', {
|
|
requestId,
|
|
duration,
|
|
submissionId: submission.id
|
|
});
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: true,
|
|
submissionId: submission.id,
|
|
ticketNumber: ticketNumber,
|
|
message: `Your message has been received (Ticket: ${ticketNumber}). We will respond within 24-48 hours.`
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
|
}
|
|
);
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
edgeLogger.error('Contact submission failed', {
|
|
requestId,
|
|
duration,
|
|
error: formatEdgeError(error)
|
|
});
|
|
return createErrorResponse(error, 500, corsHeaders);
|
|
}
|
|
};
|
|
|
|
// Apply standard rate limiting (20 req/min) for contact form submissions
|
|
// Balances legitimate user needs with spam prevention
|
|
serve(withRateLimit(handler, rateLimiters.standard, corsHeaders));
|