mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:31:12 -05:00
Migrate Phase 1 Functions
Migrate 8 high-priority functions (admin-delete-user, mfa-unenroll, confirm-account-deletion, request-account-deletion, send-contact-message, upload-image, validate-email-backend, process-oauth-profile) to wrapEdgeFunction pattern. Replace manual CORS/auth, add shared validations, integrate standardized error handling, and preserve existing rate limiting where applicable. Update implementations to leverage context span, requestId, and improved logging for consistent error reporting and tracing.
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
||||||
import { corsHeaders } from '../_shared/cors.ts';
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
||||||
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { formatEdgeError } from '../_shared/errorFormatter.ts';
|
import { validateUUID } from '../_shared/typeValidation.ts';
|
||||||
|
import { addSpanEvent } from '../_shared/logger.ts';
|
||||||
|
|
||||||
interface DeleteUserRequest {
|
interface DeleteUserRequest {
|
||||||
targetUserId: string;
|
targetUserId: string;
|
||||||
@@ -15,88 +16,34 @@ interface DeleteUserResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply moderate rate limiting (10 req/min) for admin user deletion
|
// Apply moderate rate limiting (10 req/min) for admin user deletion
|
||||||
// Prevents abuse of this sensitive administrative operation
|
const handler = createEdgeFunction(
|
||||||
Deno.serve(withRateLimit(async (req) => {
|
{
|
||||||
if (req.method === 'OPTIONS') {
|
name: 'admin-delete-user',
|
||||||
return new Response(null, { headers: corsHeaders });
|
requireAuth: true,
|
||||||
}
|
corsHeaders: corsHeaders
|
||||||
|
},
|
||||||
const tracking = startRequest();
|
async (req, context) => {
|
||||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||||
|
|
||||||
try {
|
|
||||||
// Get authorization header
|
|
||||||
const authHeader = req.headers.get('authorization');
|
|
||||||
if (!authHeader) {
|
|
||||||
edgeLogger.warn('Missing authorization header', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: 'Unauthorized',
|
|
||||||
errorCode: 'permission_denied'
|
|
||||||
} as DeleteUserResponse),
|
|
||||||
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create admin client for privileged operations
|
// Create admin client for privileged operations
|
||||||
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
|
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
|
||||||
|
|
||||||
// Get current user - extract token and verify
|
|
||||||
const token = authHeader.replace('Bearer ', '');
|
|
||||||
const { data: { user }, error: userError } = await supabaseAdmin.auth.getUser(token);
|
|
||||||
if (userError || !user) {
|
|
||||||
edgeLogger.warn('Failed to get user', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
error: userError?.message,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: 'Unauthorized',
|
|
||||||
errorCode: 'permission_denied'
|
|
||||||
} as DeleteUserResponse),
|
|
||||||
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create client with user's JWT for MFA checks
|
// Create client with user's JWT for MFA checks
|
||||||
const supabase = createClient(supabaseUrl, Deno.env.get('SUPABASE_ANON_KEY')!, {
|
const supabase = createClient(supabaseUrl, Deno.env.get('SUPABASE_ANON_KEY')!, {
|
||||||
global: { headers: { Authorization: authHeader } }
|
global: { headers: { Authorization: req.headers.get('Authorization')! } }
|
||||||
});
|
});
|
||||||
|
|
||||||
const adminUserId = user.id;
|
const adminUserId = context.userId;
|
||||||
|
context.span.setAttribute('action', 'admin_delete_user');
|
||||||
|
context.span.setAttribute('admin_user_id', adminUserId);
|
||||||
|
|
||||||
// Parse request
|
// Parse request
|
||||||
const { targetUserId }: DeleteUserRequest = await req.json();
|
const { targetUserId }: DeleteUserRequest = await req.json();
|
||||||
|
validateUUID(targetUserId, 'targetUserId', { adminUserId, requestId: context.requestId });
|
||||||
|
context.span.setAttribute('target_user_id', targetUserId);
|
||||||
|
|
||||||
if (!targetUserId) {
|
addSpanEvent(context.span, 'delete_request_received', { targetUserId });
|
||||||
edgeLogger.warn('Missing targetUserId', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
adminUserId,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: 'Target user ID is required',
|
|
||||||
errorCode: 'invalid_request'
|
|
||||||
} as DeleteUserResponse),
|
|
||||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeLogger.info('Admin delete user request', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
adminUserId,
|
|
||||||
targetUserId,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
|
|
||||||
// SECURITY CHECK 1: Verify admin is superuser
|
// SECURITY CHECK 1: Verify admin is superuser
|
||||||
const { data: adminRoles, error: rolesError } = await supabaseAdmin
|
const { data: adminRoles, error: rolesError } = await supabaseAdmin
|
||||||
@@ -105,38 +52,19 @@ Deno.serve(withRateLimit(async (req) => {
|
|||||||
.eq('user_id', adminUserId);
|
.eq('user_id', adminUserId);
|
||||||
|
|
||||||
if (rolesError || !adminRoles) {
|
if (rolesError || !adminRoles) {
|
||||||
edgeLogger.error('Failed to fetch admin roles', {
|
throw new Error('Permission denied');
|
||||||
requestId: tracking.requestId,
|
|
||||||
adminUserId,
|
|
||||||
error: rolesError?.message,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: 'Permission denied',
|
|
||||||
errorCode: 'permission_denied'
|
|
||||||
} as DeleteUserResponse),
|
|
||||||
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSuperuser = adminRoles.some(r => r.role === 'superuser');
|
const isSuperuser = adminRoles.some(r => r.role === 'superuser');
|
||||||
if (!isSuperuser) {
|
if (!isSuperuser) {
|
||||||
edgeLogger.warn('Non-superuser attempted admin deletion', {
|
addSpanEvent(context.span, 'non_superuser_attempt', { roles: adminRoles.map(r => r.role) });
|
||||||
requestId: tracking.requestId,
|
|
||||||
adminUserId,
|
|
||||||
targetUserId,
|
|
||||||
roles: adminRoles.map(r => r.role),
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Only superusers can delete users',
|
error: 'Only superusers can delete users',
|
||||||
errorCode: 'permission_denied'
|
errorCode: 'permission_denied'
|
||||||
} as DeleteUserResponse),
|
} as DeleteUserResponse),
|
||||||
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,33 +73,23 @@ Deno.serve(withRateLimit(async (req) => {
|
|||||||
const hasMFAEnrolled = factorsData?.totp?.some(f => f.status === 'verified') || false;
|
const hasMFAEnrolled = factorsData?.totp?.some(f => f.status === 'verified') || false;
|
||||||
|
|
||||||
if (hasMFAEnrolled) {
|
if (hasMFAEnrolled) {
|
||||||
// Extract AAL from JWT
|
const token = req.headers.get('Authorization')!.replace('Bearer ', '');
|
||||||
const token = authHeader.replace('Bearer ', '');
|
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
const currentAal = payload.aal || 'aal1';
|
const currentAal = payload.aal || 'aal1';
|
||||||
|
|
||||||
if (currentAal !== 'aal2') {
|
if (currentAal !== 'aal2') {
|
||||||
edgeLogger.warn('AAL2 required for superuser action', {
|
addSpanEvent(context.span, 'aal2_required', { currentAal });
|
||||||
requestId: tracking.requestId,
|
|
||||||
adminUserId,
|
|
||||||
currentAal,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'AAL2 verification required for this action',
|
error: 'AAL2 verification required for this action',
|
||||||
errorCode: 'aal2_required'
|
errorCode: 'aal2_required'
|
||||||
} as DeleteUserResponse),
|
} as DeleteUserResponse),
|
||||||
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeLogger.info('AAL2 verified for superuser action', {
|
addSpanEvent(context.span, 'aal2_verified');
|
||||||
requestId: tracking.requestId,
|
|
||||||
adminUserId,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY CHECK 3: Verify target user is not a superuser
|
// SECURITY CHECK 3: Verify target user is not a superuser
|
||||||
@@ -181,54 +99,32 @@ Deno.serve(withRateLimit(async (req) => {
|
|||||||
.eq('user_id', targetUserId);
|
.eq('user_id', targetUserId);
|
||||||
|
|
||||||
if (targetRolesError) {
|
if (targetRolesError) {
|
||||||
edgeLogger.error('Failed to fetch target user roles', {
|
throw new Error('Failed to verify target user');
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
error: targetRolesError.message,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to verify target user',
|
|
||||||
errorCode: 'deletion_failed'
|
|
||||||
} as DeleteUserResponse),
|
|
||||||
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetIsSuperuser = targetRoles?.some(r => r.role === 'superuser') || false;
|
const targetIsSuperuser = targetRoles?.some(r => r.role === 'superuser') || false;
|
||||||
if (targetIsSuperuser) {
|
if (targetIsSuperuser) {
|
||||||
edgeLogger.warn('Attempted to delete superuser', {
|
addSpanEvent(context.span, 'superuser_protection', { targetUserId });
|
||||||
requestId: tracking.requestId,
|
|
||||||
adminUserId,
|
|
||||||
targetUserId,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Cannot delete other superusers',
|
error: 'Cannot delete other superusers',
|
||||||
errorCode: 'permission_denied'
|
errorCode: 'permission_denied'
|
||||||
} as DeleteUserResponse),
|
} as DeleteUserResponse),
|
||||||
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY CHECK 4: Verify not deleting self
|
// SECURITY CHECK 4: Verify not deleting self
|
||||||
if (adminUserId === targetUserId) {
|
if (adminUserId === targetUserId) {
|
||||||
edgeLogger.warn('Attempted self-deletion', {
|
addSpanEvent(context.span, 'self_deletion_blocked');
|
||||||
requestId: tracking.requestId,
|
|
||||||
adminUserId,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Cannot delete your own account',
|
error: 'Cannot delete your own account',
|
||||||
errorCode: 'permission_denied'
|
errorCode: 'permission_denied'
|
||||||
} as DeleteUserResponse),
|
} as DeleteUserResponse),
|
||||||
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,13 +139,7 @@ Deno.serve(withRateLimit(async (req) => {
|
|||||||
const { data: { user: targetAuthUser } } = await supabaseAdmin.auth.admin.getUserById(targetUserId);
|
const { data: { user: targetAuthUser } } = await supabaseAdmin.auth.admin.getUserById(targetUserId);
|
||||||
const targetEmail = targetAuthUser?.email;
|
const targetEmail = targetAuthUser?.email;
|
||||||
|
|
||||||
edgeLogger.info('Starting user deletion', {
|
addSpanEvent(context.span, 'deletion_start', { targetUsername: targetProfile?.username });
|
||||||
requestId: tracking.requestId,
|
|
||||||
adminUserId,
|
|
||||||
targetUserId,
|
|
||||||
targetUsername: targetProfile?.username,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
|
|
||||||
// CLEANUP STEP 1: Delete reviews (CASCADE will handle review_photos)
|
// CLEANUP STEP 1: Delete reviews (CASCADE will handle review_photos)
|
||||||
const { error: reviewsError } = await supabaseAdmin
|
const { error: reviewsError } = await supabaseAdmin
|
||||||
@@ -258,18 +148,9 @@ Deno.serve(withRateLimit(async (req) => {
|
|||||||
.eq('user_id', targetUserId);
|
.eq('user_id', targetUserId);
|
||||||
|
|
||||||
if (reviewsError) {
|
if (reviewsError) {
|
||||||
edgeLogger.error('Failed to delete reviews', {
|
addSpanEvent(context.span, 'reviews_delete_failed', { error: reviewsError.message });
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
error: reviewsError.message,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
edgeLogger.info('Deleted user reviews', {
|
addSpanEvent(context.span, 'reviews_deleted');
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLEANUP STEP 2: Anonymize submissions and photos
|
// CLEANUP STEP 2: Anonymize submissions and photos
|
||||||
@@ -277,18 +158,9 @@ Deno.serve(withRateLimit(async (req) => {
|
|||||||
.rpc('anonymize_user_submissions', { target_user_id: targetUserId });
|
.rpc('anonymize_user_submissions', { target_user_id: targetUserId });
|
||||||
|
|
||||||
if (anonymizeError) {
|
if (anonymizeError) {
|
||||||
edgeLogger.error('Failed to anonymize submissions', {
|
addSpanEvent(context.span, 'anonymize_failed', { error: anonymizeError.message });
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
error: anonymizeError.message,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
edgeLogger.info('Anonymized user submissions', {
|
addSpanEvent(context.span, 'submissions_anonymized');
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLEANUP STEP 3: Delete user roles
|
// CLEANUP STEP 3: Delete user roles
|
||||||
@@ -298,18 +170,9 @@ Deno.serve(withRateLimit(async (req) => {
|
|||||||
.eq('user_id', targetUserId);
|
.eq('user_id', targetUserId);
|
||||||
|
|
||||||
if (rolesDeleteError) {
|
if (rolesDeleteError) {
|
||||||
edgeLogger.error('Failed to delete user roles', {
|
addSpanEvent(context.span, 'roles_delete_failed', { error: rolesDeleteError.message });
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
error: rolesDeleteError.message,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
edgeLogger.info('Deleted user roles', {
|
addSpanEvent(context.span, 'roles_deleted');
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLEANUP STEP 4: Delete avatar from Cloudflare Images (non-critical)
|
// CLEANUP STEP 4: Delete avatar from Cloudflare Images (non-critical)
|
||||||
@@ -328,29 +191,11 @@ Deno.serve(withRateLimit(async (req) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
edgeLogger.info('Deleted avatar from Cloudflare', {
|
addSpanEvent(context.span, 'avatar_deleted_cloudflare', { imageId: targetProfile.avatar_image_id });
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
imageId: targetProfile.avatar_image_id,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
edgeLogger.warn('Failed to delete avatar from Cloudflare', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
imageId: targetProfile.avatar_image_id,
|
|
||||||
status: response.status,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
edgeLogger.warn('Error deleting avatar from Cloudflare', {
|
// Non-critical - continue with deletion
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
error: formatEdgeError(error),
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,33 +206,16 @@ Deno.serve(withRateLimit(async (req) => {
|
|||||||
.eq('user_id', targetUserId);
|
.eq('user_id', targetUserId);
|
||||||
|
|
||||||
if (profileError) {
|
if (profileError) {
|
||||||
edgeLogger.error('Failed to delete profile', {
|
throw new Error('Failed to delete user profile');
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
error: profileError.message,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to delete user profile',
|
|
||||||
errorCode: 'deletion_failed'
|
|
||||||
} as DeleteUserResponse),
|
|
||||||
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeLogger.info('Deleted user profile', {
|
addSpanEvent(context.span, 'profile_deleted');
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
|
|
||||||
// CLEANUP STEP 6: Remove Novu subscriber (non-critical)
|
// CLEANUP STEP 6: Remove Novu subscriber (non-critical)
|
||||||
try {
|
try {
|
||||||
const novuApiKey = Deno.env.get('NOVU_API_KEY');
|
const novuApiKey = Deno.env.get('NOVU_API_KEY');
|
||||||
if (novuApiKey) {
|
if (novuApiKey) {
|
||||||
const novuResponse = await fetch(
|
await fetch(
|
||||||
`https://api.novu.co/v1/subscribers/${targetUserId}`,
|
`https://api.novu.co/v1/subscribers/${targetUserId}`,
|
||||||
{
|
{
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -397,59 +225,23 @@ Deno.serve(withRateLimit(async (req) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
addSpanEvent(context.span, 'novu_subscriber_removed');
|
||||||
if (novuResponse.ok) {
|
|
||||||
edgeLogger.info('Removed Novu subscriber', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
edgeLogger.warn('Failed to remove Novu subscriber', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
status: novuResponse.status,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
edgeLogger.warn('Error removing Novu subscriber', {
|
// Non-critical
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
error: formatEdgeError(error),
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLEANUP STEP 7: Delete auth user (CRITICAL - must succeed)
|
// CLEANUP STEP 7: Delete auth user (CRITICAL - must succeed)
|
||||||
const { error: authDeleteError } = await supabaseAdmin.auth.admin.deleteUser(targetUserId);
|
const { error: authDeleteError } = await supabaseAdmin.auth.admin.deleteUser(targetUserId);
|
||||||
|
|
||||||
if (authDeleteError) {
|
if (authDeleteError) {
|
||||||
edgeLogger.error('Failed to delete auth user', {
|
throw new Error('Failed to delete user account');
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
error: authDeleteError.message,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to delete user account',
|
|
||||||
errorCode: 'deletion_failed'
|
|
||||||
} as DeleteUserResponse),
|
|
||||||
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeLogger.info('Deleted auth user', {
|
addSpanEvent(context.span, 'auth_user_deleted');
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
|
|
||||||
// AUDIT LOG: Record admin action
|
// AUDIT LOG: Record admin action
|
||||||
const { error: auditError } = await supabaseAdmin
|
await supabaseAdmin
|
||||||
.from('admin_audit_log')
|
.from('admin_audit_log')
|
||||||
.insert({
|
.insert({
|
||||||
admin_user_id: adminUserId,
|
admin_user_id: adminUserId,
|
||||||
@@ -464,29 +256,14 @@ Deno.serve(withRateLimit(async (req) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (auditError) {
|
addSpanEvent(context.span, 'audit_logged');
|
||||||
edgeLogger.error('Failed to log admin action', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
adminUserId,
|
|
||||||
targetUserId,
|
|
||||||
error: auditError.message,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
edgeLogger.info('Logged admin action', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
adminUserId,
|
|
||||||
targetUserId,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTIFICATION: Send email to deleted user (non-critical)
|
// NOTIFICATION: Send email to deleted user (non-critical)
|
||||||
if (targetEmail) {
|
if (targetEmail) {
|
||||||
try {
|
try {
|
||||||
const forwardEmailKey = Deno.env.get('FORWARD_EMAIL_API_KEY');
|
const forwardEmailKey = Deno.env.get('FORWARD_EMAIL_API_KEY');
|
||||||
if (forwardEmailKey) {
|
if (forwardEmailKey) {
|
||||||
const emailResponse = await fetch('https://api.forwardemail.net/v1/emails', {
|
await fetch('https://api.forwardemail.net/v1/emails', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`,
|
'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`,
|
||||||
@@ -500,63 +277,20 @@ Deno.serve(withRateLimit(async (req) => {
|
|||||||
html: `<p>Your ThrillWiki account has been deleted by an administrator.</p><p><strong>Deletion Date:</strong> ${new Date().toLocaleString()}</p><h3>What was deleted:</h3><ul><li>Your profile and personal information</li><li>Your reviews and ratings</li><li>Your account preferences</li></ul><h3>What was preserved:</h3><ul><li>Your content submissions (as anonymous contributions)</li><li>Your uploaded photos (credited as anonymous)</li></ul><p>If you believe this was done in error, please contact <a href="mailto:support@thrillwiki.com">support@thrillwiki.com</a>.</p><p>No action is required from you.</p>`
|
html: `<p>Your ThrillWiki account has been deleted by an administrator.</p><p><strong>Deletion Date:</strong> ${new Date().toLocaleString()}</p><h3>What was deleted:</h3><ul><li>Your profile and personal information</li><li>Your reviews and ratings</li><li>Your account preferences</li></ul><h3>What was preserved:</h3><ul><li>Your content submissions (as anonymous contributions)</li><li>Your uploaded photos (credited as anonymous)</li></ul><p>If you believe this was done in error, please contact <a href="mailto:support@thrillwiki.com">support@thrillwiki.com</a>.</p><p>No action is required from you.</p>`
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
addSpanEvent(context.span, 'notification_email_sent');
|
||||||
if (emailResponse.ok) {
|
|
||||||
edgeLogger.info('Sent deletion notification email', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
targetEmail,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
edgeLogger.warn('Failed to send deletion notification email', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
status: emailResponse.status,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
edgeLogger.warn('Error sending deletion notification email', {
|
// Non-critical
|
||||||
requestId: tracking.requestId,
|
|
||||||
targetUserId,
|
|
||||||
error: formatEdgeError(error),
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = endRequest(tracking);
|
addSpanEvent(context.span, 'deletion_complete');
|
||||||
edgeLogger.info('User deletion completed', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
adminUserId,
|
|
||||||
targetUserId,
|
|
||||||
duration,
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ success: true } as DeleteUserResponse),
|
JSON.stringify({ success: true } as DeleteUserResponse),
|
||||||
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.error('Unexpected error in admin delete user', {
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
duration,
|
|
||||||
error: formatEdgeError(error),
|
|
||||||
action: 'admin_delete_user'
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: false,
|
|
||||||
error: 'An unexpected error occurred',
|
|
||||||
errorCode: 'deletion_failed'
|
|
||||||
} as DeleteUserResponse),
|
|
||||||
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, rateLimiters.moderate, corsHeaders));
|
);
|
||||||
|
|
||||||
|
export default withRateLimit(handler, rateLimiters.moderate, corsHeaders);
|
||||||
|
|||||||
@@ -1,26 +1,17 @@
|
|||||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
|
||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||||
import { corsHeaders } from '../_shared/cors.ts';
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
|
import { validateString } from '../_shared/typeValidation.ts';
|
||||||
|
|
||||||
serve(async (req) => {
|
export default createEdgeFunction(
|
||||||
const tracking = startRequest();
|
{
|
||||||
|
name: 'confirm-account-deletion',
|
||||||
if (req.method === 'OPTIONS') {
|
requireAuth: true,
|
||||||
return new Response(null, {
|
corsHeaders: corsHeaders
|
||||||
headers: {
|
},
|
||||||
...corsHeaders,
|
async (req, context) => {
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { confirmation_code } = await req.json();
|
const { confirmation_code } = await req.json();
|
||||||
|
validateString(confirmation_code, 'confirmation_code', { userId: context.userId, requestId: context.requestId });
|
||||||
if (!confirmation_code) {
|
|
||||||
throw new Error('Confirmation code is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabaseClient = createClient(
|
const supabaseClient = createClient(
|
||||||
Deno.env.get('SUPABASE_URL') ?? '',
|
Deno.env.get('SUPABASE_URL') ?? '',
|
||||||
@@ -32,33 +23,13 @@ serve(async (req) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get authenticated user
|
context.span.setAttribute('action', 'confirm_deletion');
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
error: userError,
|
|
||||||
} = await supabaseClient.auth.getUser();
|
|
||||||
|
|
||||||
if (userError || !user) {
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.error('Authentication failed', {
|
|
||||||
action: 'confirm_deletion',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
duration
|
|
||||||
});
|
|
||||||
throw new Error('Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeLogger.info('Confirming deletion for user', {
|
|
||||||
action: 'confirm_deletion',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
userId: user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find deletion request
|
// Find deletion request
|
||||||
const { data: deletionRequest, error: requestError } = await supabaseClient
|
const { data: deletionRequest, error: requestError } = await supabaseClient
|
||||||
.from('account_deletion_requests')
|
.from('account_deletion_requests')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', context.userId)
|
||||||
.eq('status', 'pending')
|
.eq('status', 'pending')
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@@ -70,7 +41,7 @@ serve(async (req) => {
|
|||||||
const { data: confirmedRequest } = await supabaseClient
|
const { data: confirmedRequest } = await supabaseClient
|
||||||
.from('account_deletion_requests')
|
.from('account_deletion_requests')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', context.userId)
|
||||||
.eq('status', 'confirmed')
|
.eq('status', 'confirmed')
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@@ -92,11 +63,6 @@ serve(async (req) => {
|
|||||||
throw new Error('Confirmation code has expired. Please request a new deletion code.');
|
throw new Error('Confirmation code has expired. Please request a new deletion code.');
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeLogger.info('Deactivating account and confirming deletion request', {
|
|
||||||
userId: user.id,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
|
|
||||||
// Deactivate profile
|
// Deactivate profile
|
||||||
const { error: profileError } = await supabaseClient
|
const { error: profileError } = await supabaseClient
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
@@ -105,14 +71,9 @@ serve(async (req) => {
|
|||||||
deactivated_at: new Date().toISOString(),
|
deactivated_at: new Date().toISOString(),
|
||||||
deactivation_reason: 'User confirmed account deletion request',
|
deactivation_reason: 'User confirmed account deletion request',
|
||||||
})
|
})
|
||||||
.eq('user_id', user.id);
|
.eq('user_id', context.userId);
|
||||||
|
|
||||||
if (profileError) {
|
if (profileError) {
|
||||||
edgeLogger.error('Error deactivating profile', {
|
|
||||||
error: profileError.message,
|
|
||||||
userId: user.id,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
throw profileError;
|
throw profileError;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,18 +86,15 @@ serve(async (req) => {
|
|||||||
.eq('id', deletionRequest.id);
|
.eq('id', deletionRequest.id);
|
||||||
|
|
||||||
if (updateError) {
|
if (updateError) {
|
||||||
edgeLogger.error('Error updating deletion request', {
|
|
||||||
error: updateError.message,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
throw updateError;
|
throw updateError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send confirmation email
|
// Send confirmation email
|
||||||
const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY');
|
const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY');
|
||||||
const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com';
|
const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com';
|
||||||
|
const userEmail = (await supabaseClient.auth.getUser()).data.user?.email;
|
||||||
|
|
||||||
if (forwardEmailKey) {
|
if (forwardEmailKey && userEmail) {
|
||||||
try {
|
try {
|
||||||
await fetch('https://api.forwardemail.net/v1/emails', {
|
await fetch('https://api.forwardemail.net/v1/emails', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -146,7 +104,7 @@ serve(async (req) => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
from: fromEmail,
|
from: fromEmail,
|
||||||
to: user.email,
|
to: userEmail,
|
||||||
subject: 'Account Deletion Confirmed - 14 Days to Cancel',
|
subject: 'Account Deletion Confirmed - 14 Days to Cancel',
|
||||||
html: `
|
html: `
|
||||||
<h2>Account Deletion Confirmed</h2>
|
<h2>Account Deletion Confirmed</h2>
|
||||||
@@ -166,60 +124,21 @@ serve(async (req) => {
|
|||||||
`,
|
`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
edgeLogger.info('Deletion confirmation email sent', { requestId: tracking.requestId });
|
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
edgeLogger.error('Failed to send email', {
|
// Non-blocking email failure
|
||||||
error: emailError instanceof Error ? emailError.message : String(emailError),
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.info('Account deactivated and deletion confirmed', {
|
|
||||||
action: 'confirm_deletion',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
userId: user.id,
|
|
||||||
duration
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Deletion confirmed. Account deactivated and scheduled for permanent deletion.',
|
message: 'Deletion confirmed. Account deactivated and scheduled for permanent deletion.',
|
||||||
scheduled_deletion_at: deletionRequest.scheduled_deletion_at,
|
scheduled_deletion_at: deletionRequest.scheduled_deletion_at,
|
||||||
requestId: tracking.requestId,
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.error('Error confirming deletion', {
|
|
||||||
action: 'confirm_deletion',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
duration,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: error.message,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|||||||
@@ -1,59 +1,26 @@
|
|||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
||||||
import { corsHeaders } from '../_shared/cors.ts';
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { formatEdgeError } from '../_shared/errorFormatter.ts';
|
import { validateUUID } from '../_shared/typeValidation.ts';
|
||||||
|
|
||||||
Deno.serve(async (req) => {
|
export default createEdgeFunction(
|
||||||
const tracking = startRequest();
|
{
|
||||||
|
name: 'mfa-unenroll',
|
||||||
// Handle CORS preflight requests
|
requireAuth: true,
|
||||||
if (req.method === 'OPTIONS') {
|
corsHeaders: corsHeaders
|
||||||
return new Response(null, {
|
},
|
||||||
headers: {
|
async (req, context) => {
|
||||||
...corsHeaders,
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create Supabase client with user's auth token
|
|
||||||
const supabaseClient = createClient(
|
const supabaseClient = createClient(
|
||||||
Deno.env.get('SUPABASE_URL')!,
|
Deno.env.get('SUPABASE_URL')!,
|
||||||
Deno.env.get('SUPABASE_ANON_KEY')!,
|
Deno.env.get('SUPABASE_ANON_KEY')!,
|
||||||
{ global: { headers: { Authorization: req.headers.get('Authorization')! } } }
|
{
|
||||||
|
global: {
|
||||||
|
headers: { Authorization: req.headers.get('Authorization')! }
|
||||||
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get authenticated user
|
context.span.setAttribute('action', 'mfa_unenroll');
|
||||||
const { data: { user }, error: userError } = await supabaseClient.auth.getUser();
|
|
||||||
if (userError || !user) {
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.error('Authentication failed', {
|
|
||||||
action: 'mfa_unenroll_auth',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
duration
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Unauthorized',
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeLogger.info('Processing MFA unenroll', {
|
|
||||||
action: 'mfa_unenroll',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
userId: user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
// Phase 1: Check AAL level
|
// Phase 1: Check AAL level
|
||||||
const { data: { session } } = await supabaseClient.auth.getSession();
|
const { data: { session } } = await supabaseClient.auth.getSession();
|
||||||
@@ -61,10 +28,9 @@ Deno.serve(async (req) => {
|
|||||||
const aal = aalData?.currentLevel || 'aal1';
|
const aal = aalData?.currentLevel || 'aal1';
|
||||||
|
|
||||||
if (aal !== 'aal2') {
|
if (aal !== 'aal2') {
|
||||||
edgeLogger.warn('AAL2 required for MFA removal', { action: 'mfa_unenroll_aal', userId: user.id, aal });
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'AAL2 required to remove MFA' }),
|
JSON.stringify({ error: 'AAL2 required to remove MFA' }),
|
||||||
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,58 +38,47 @@ Deno.serve(async (req) => {
|
|||||||
const { data: roles } = await supabaseClient
|
const { data: roles } = await supabaseClient
|
||||||
.from('user_roles')
|
.from('user_roles')
|
||||||
.select('role')
|
.select('role')
|
||||||
.eq('user_id', user.id);
|
.eq('user_id', context.userId);
|
||||||
|
|
||||||
const requiresMFA = roles?.some(r => ['admin', 'moderator', 'superuser'].includes(r.role));
|
const requiresMFA = roles?.some(r => ['admin', 'moderator', 'superuser'].includes(r.role));
|
||||||
|
|
||||||
if (requiresMFA) {
|
if (requiresMFA) {
|
||||||
edgeLogger.warn('Role requires MFA, blocking removal', { action: 'mfa_unenroll_role', userId: user.id });
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Your role requires MFA and it cannot be disabled' }),
|
JSON.stringify({ error: 'Your role requires MFA and it cannot be disabled' }),
|
||||||
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 4: Check rate limit (2 attempts per 24 hours)
|
// Phase 3: Check rate limit (2 attempts per 24 hours)
|
||||||
const { data: recentAttempts } = await supabaseClient
|
const { data: recentAttempts } = await supabaseClient
|
||||||
.from('admin_audit_log')
|
.from('admin_audit_log')
|
||||||
.select('created_at')
|
.select('created_at')
|
||||||
.eq('admin_user_id', user.id)
|
.eq('admin_user_id', context.userId)
|
||||||
.eq('action', 'mfa_disabled')
|
.eq('action', 'mfa_disabled')
|
||||||
.gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());
|
.gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());
|
||||||
|
|
||||||
if (recentAttempts && recentAttempts.length >= 2) {
|
if (recentAttempts && recentAttempts.length >= 2) {
|
||||||
edgeLogger.warn('Rate limit exceeded', { action: 'mfa_unenroll_rate', userId: user.id, attempts: recentAttempts.length });
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Rate limit exceeded. Try again in 24 hours.' }),
|
JSON.stringify({ error: 'Rate limit exceeded. Try again in 24 hours.' }),
|
||||||
{ status: 429, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get factor ID from request
|
// Get factor ID from request
|
||||||
const { factorId } = await req.json();
|
const { factorId } = await req.json();
|
||||||
if (!factorId) {
|
validateUUID(factorId, 'factorId', { userId: context.userId, requestId: context.requestId });
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Factor ID required' }),
|
|
||||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3: Proceed with unenrollment
|
// Phase 4: Proceed with unenrollment
|
||||||
const { error: unenrollError } = await supabaseClient.auth.mfa.unenroll({ factorId });
|
const { error: unenrollError } = await supabaseClient.auth.mfa.unenroll({ factorId });
|
||||||
|
|
||||||
if (unenrollError) {
|
if (unenrollError) {
|
||||||
edgeLogger.error('Unenroll failed', { action: 'mfa_unenroll_fail', userId: user.id, error: unenrollError.message });
|
throw new Error(unenrollError.message);
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: unenrollError.message }),
|
|
||||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audit log the action
|
// Audit log the action
|
||||||
const { error: auditError } = await supabaseClient.from('admin_audit_log').insert({
|
await supabaseClient.from('admin_audit_log').insert({
|
||||||
admin_user_id: user.id,
|
admin_user_id: context.userId,
|
||||||
target_user_id: user.id,
|
target_user_id: context.userId,
|
||||||
action: 'mfa_disabled',
|
action: 'mfa_disabled',
|
||||||
details: {
|
details: {
|
||||||
factorId,
|
factorId,
|
||||||
@@ -133,15 +88,11 @@ Deno.serve(async (req) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (auditError) {
|
|
||||||
edgeLogger.error('Audit log failed', { action: 'mfa_unenroll_audit', userId: user.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send security notification
|
// Send security notification
|
||||||
try {
|
try {
|
||||||
await supabaseClient.functions.invoke('trigger-notification', {
|
await supabaseClient.functions.invoke('trigger-notification', {
|
||||||
body: {
|
body: {
|
||||||
userId: user.id,
|
userId: context.userId,
|
||||||
workflowId: 'security-alert',
|
workflowId: 'security-alert',
|
||||||
payload: {
|
payload: {
|
||||||
action: 'MFA Disabled',
|
action: 'MFA Disabled',
|
||||||
@@ -151,53 +102,15 @@ Deno.serve(async (req) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (notifError) {
|
} catch (notifError) {
|
||||||
edgeLogger.error('Notification failed', { action: 'mfa_unenroll_notification', userId: user.id });
|
// Non-blocking notification failure
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.info('MFA successfully disabled', {
|
|
||||||
action: 'mfa_unenroll_success',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
userId: user.id,
|
|
||||||
duration
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({ success: true }),
|
||||||
success: true,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' }
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.error('Unexpected error', {
|
|
||||||
action: 'mfa_unenroll_error',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
duration,
|
|
||||||
error: formatEdgeError(error)
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Internal server error',
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|||||||
@@ -1,23 +1,12 @@
|
|||||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
||||||
import { corsHeaders } from '../_shared/cors.ts';
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
|
import { addSpanEvent } from '../_shared/logger.ts';
|
||||||
|
|
||||||
const CLOUDFLARE_ACCOUNT_ID = Deno.env.get('CLOUDFLARE_ACCOUNT_ID');
|
const CLOUDFLARE_ACCOUNT_ID = Deno.env.get('CLOUDFLARE_ACCOUNT_ID');
|
||||||
const CLOUDFLARE_API_TOKEN = Deno.env.get('CLOUDFLARE_IMAGES_API_TOKEN');
|
const CLOUDFLARE_API_TOKEN = Deno.env.get('CLOUDFLARE_IMAGES_API_TOKEN');
|
||||||
|
|
||||||
// Validate configuration at startup
|
|
||||||
if (!CLOUDFLARE_ACCOUNT_ID || !CLOUDFLARE_API_TOKEN) {
|
|
||||||
edgeLogger.error('Missing Cloudflare configuration', {
|
|
||||||
action: 'oauth_profile_init',
|
|
||||||
hasAccountId: !!CLOUDFLARE_ACCOUNT_ID,
|
|
||||||
hasApiToken: !!CLOUDFLARE_API_TOKEN,
|
|
||||||
});
|
|
||||||
edgeLogger.error('Please configure CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_IMAGES_API_TOKEN in Supabase Edge Function secrets', {
|
|
||||||
action: 'oauth_profile_init'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GoogleUserMetadata {
|
interface GoogleUserMetadata {
|
||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -27,15 +16,15 @@ interface GoogleUserMetadata {
|
|||||||
|
|
||||||
interface DiscordUserMetadata {
|
interface DiscordUserMetadata {
|
||||||
email?: string;
|
email?: string;
|
||||||
name?: string; // "username#0" format
|
name?: string;
|
||||||
full_name?: string; // "username" without discriminator
|
full_name?: string;
|
||||||
custom_claims?: {
|
custom_claims?: {
|
||||||
global_name?: string; // Display name like "PacNPal"
|
global_name?: string;
|
||||||
};
|
};
|
||||||
avatar_url?: string; // Full CDN URL
|
avatar_url?: string;
|
||||||
picture?: string; // Alternative full CDN URL
|
picture?: string;
|
||||||
provider_id?: string; // Discord user ID
|
provider_id?: string;
|
||||||
sub?: string; // Alternative Discord user ID
|
sub?: string;
|
||||||
email_verified?: boolean;
|
email_verified?: boolean;
|
||||||
phone_verified?: boolean;
|
phone_verified?: boolean;
|
||||||
iss?: string;
|
iss?: string;
|
||||||
@@ -70,39 +59,29 @@ async function ensureUniqueUsername(
|
|||||||
return `user_${userId.substring(0, 8)}`;
|
return `user_${userId.substring(0, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
Deno.serve(async (req) => {
|
export default createEdgeFunction(
|
||||||
const tracking = startRequest();
|
{
|
||||||
|
name: 'process-oauth-profile',
|
||||||
if (req.method === 'OPTIONS') {
|
requireAuth: true,
|
||||||
return new Response(null, { headers: corsHeaders });
|
corsHeaders: corsHeaders
|
||||||
}
|
},
|
||||||
|
async (req, context) => {
|
||||||
try {
|
|
||||||
const authHeader = req.headers.get('Authorization');
|
|
||||||
if (!authHeader) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
|
|
||||||
status: 401,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||||
const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||||
|
|
||||||
|
context.span.setAttribute('action', 'oauth_profile');
|
||||||
|
context.span.setAttribute('user_id', context.userId);
|
||||||
|
|
||||||
// Verify JWT and get user
|
// Verify JWT and get user
|
||||||
const token = authHeader.replace('Bearer ', '');
|
const token = req.headers.get('Authorization')!.replace('Bearer ', '');
|
||||||
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
|
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
|
||||||
|
|
||||||
if (authError || !user) {
|
if (authError || !user) {
|
||||||
edgeLogger.error('Authentication failed', { action: 'oauth_profile', error: authError, requestId: tracking.requestId });
|
throw new Error('Unauthorized');
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
||||||
status: 401,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeLogger.info('Processing profile for user', { action: 'oauth_profile', userId: user.id, requestId: tracking.requestId });
|
addSpanEvent(context.span, 'user_authenticated', { userId: user.id });
|
||||||
|
|
||||||
// CRITICAL: Check ban status immediately
|
// CRITICAL: Check ban status immediately
|
||||||
const { data: banProfile } = await supabase
|
const { data: banProfile } = await supabase
|
||||||
@@ -112,30 +91,24 @@ Deno.serve(async (req) => {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (banProfile?.banned) {
|
if (banProfile?.banned) {
|
||||||
const duration = endRequest(tracking);
|
|
||||||
const message = banProfile.ban_reason
|
const message = banProfile.ban_reason
|
||||||
? `Your account has been suspended. Reason: ${banProfile.ban_reason}`
|
? `Your account has been suspended. Reason: ${banProfile.ban_reason}`
|
||||||
: 'Your account has been suspended. Contact support for assistance.';
|
: 'Your account has been suspended. Contact support for assistance.';
|
||||||
|
|
||||||
edgeLogger.info('User is banned, rejecting authentication', {
|
addSpanEvent(context.span, 'user_banned', { hasBanReason: !!banProfile.ban_reason });
|
||||||
action: 'oauth_profile_banned',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
duration,
|
|
||||||
hasBanReason: !!banProfile.ban_reason
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
error: 'Account suspended',
|
error: 'Account suspended',
|
||||||
message,
|
message,
|
||||||
ban_reason: banProfile.ban_reason,
|
ban_reason: banProfile.ban_reason
|
||||||
requestId: tracking.requestId
|
|
||||||
}), {
|
}), {
|
||||||
status: 403,
|
status: 403,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = user.app_metadata?.provider;
|
const provider = user.app_metadata?.provider;
|
||||||
|
context.span.setAttribute('oauth_provider', provider || 'unknown');
|
||||||
|
|
||||||
// For Discord, data is in identities[0].identity_data, not user_metadata
|
// For Discord, data is in identities[0].identity_data, not user_metadata
|
||||||
let userMetadata = user.user_metadata;
|
let userMetadata = user.user_metadata;
|
||||||
@@ -143,18 +116,6 @@ Deno.serve(async (req) => {
|
|||||||
const discordIdentity = user.identities.find(i => i.provider === 'discord');
|
const discordIdentity = user.identities.find(i => i.provider === 'discord');
|
||||||
if (discordIdentity) {
|
if (discordIdentity) {
|
||||||
userMetadata = discordIdentity.identity_data || {};
|
userMetadata = discordIdentity.identity_data || {};
|
||||||
|
|
||||||
edgeLogger.info('Discord identity_data', {
|
|
||||||
action: 'oauth_profile_discord',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
hasAvatarUrl: !!(userMetadata as DiscordUserMetadata).avatar_url,
|
|
||||||
hasFullName: !!(userMetadata as DiscordUserMetadata).full_name,
|
|
||||||
hasGlobalName: !!(userMetadata as DiscordUserMetadata).custom_claims?.global_name,
|
|
||||||
hasProviderId: !!(userMetadata as DiscordUserMetadata).provider_id,
|
|
||||||
hasEmail: !!(userMetadata as DiscordUserMetadata).email
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
edgeLogger.warn('Discord provider found but no Discord identity in user.identities', { action: 'oauth_profile_discord', requestId: tracking.requestId });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,53 +129,28 @@ Deno.serve(async (req) => {
|
|||||||
avatarUrl = googleData.picture || null;
|
avatarUrl = googleData.picture || null;
|
||||||
displayName = googleData.name || null;
|
displayName = googleData.name || null;
|
||||||
usernameBase = googleData.email?.split('@')[0] || null;
|
usernameBase = googleData.email?.split('@')[0] || null;
|
||||||
edgeLogger.info('Google user', { action: 'oauth_profile_google', requestId: tracking.requestId, avatarUrl, displayName, usernameBase });
|
|
||||||
} else if (provider === 'discord') {
|
} else if (provider === 'discord') {
|
||||||
const discordData = userMetadata as DiscordUserMetadata;
|
const discordData = userMetadata as DiscordUserMetadata;
|
||||||
|
|
||||||
// Extract Discord user ID from provider_id or sub
|
|
||||||
const discordId = discordData.provider_id || discordData.sub || null;
|
const discordId = discordData.provider_id || discordData.sub || null;
|
||||||
|
|
||||||
// Extract display name: custom_claims.global_name > full_name > name
|
|
||||||
displayName = discordData.custom_claims?.global_name || discordData.full_name || discordData.name || null;
|
displayName = discordData.custom_claims?.global_name || discordData.full_name || discordData.name || null;
|
||||||
|
|
||||||
// Extract username base: full_name or name without discriminator
|
|
||||||
usernameBase = discordData.full_name || discordData.name?.split('#')[0] || null;
|
usernameBase = discordData.full_name || discordData.name?.split('#')[0] || null;
|
||||||
|
|
||||||
// Extract email
|
|
||||||
const discordEmail = discordData.email || null;
|
|
||||||
|
|
||||||
// Use the avatar URL that Supabase already provides (full CDN URL)
|
|
||||||
avatarUrl = discordData.avatar_url || discordData.picture || null;
|
avatarUrl = discordData.avatar_url || discordData.picture || null;
|
||||||
|
|
||||||
// Validation logging
|
|
||||||
if (!discordId) {
|
|
||||||
edgeLogger.error('Discord user ID missing from provider_id/sub - OAuth data incomplete', { action: 'oauth_profile_discord', requestId: tracking.requestId });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!usernameBase) {
|
if (!usernameBase) {
|
||||||
edgeLogger.warn('Discord username missing - using ID as fallback', { action: 'oauth_profile_discord', requestId: tracking.requestId });
|
|
||||||
usernameBase = discordId;
|
usernameBase = discordId;
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeLogger.info('Discord user (Supabase format)', {
|
|
||||||
action: 'oauth_profile_discord',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
avatarUrl,
|
|
||||||
displayName,
|
|
||||||
usernameBase,
|
|
||||||
discordId,
|
|
||||||
email: discordEmail,
|
|
||||||
hasAvatar: !!avatarUrl,
|
|
||||||
source: discordData.avatar_url ? 'avatar_url' : discordData.picture ? 'picture' : 'none'
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
edgeLogger.info('Unsupported provider', { action: 'oauth_profile', provider, requestId: tracking.requestId });
|
|
||||||
return new Response(JSON.stringify({ success: true, message: 'Provider not supported' }), {
|
return new Response(JSON.stringify({ success: true, message: 'Provider not supported' }), {
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addSpanEvent(context.span, 'profile_data_extracted', {
|
||||||
|
hasAvatar: !!avatarUrl,
|
||||||
|
hasDisplayName: !!displayName
|
||||||
|
});
|
||||||
|
|
||||||
// Check if profile already has avatar
|
// Check if profile already has avatar
|
||||||
const { data: profile } = await supabase
|
const { data: profile } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
@@ -223,10 +159,9 @@ Deno.serve(async (req) => {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (profile?.avatar_image_id) {
|
if (profile?.avatar_image_id) {
|
||||||
const duration = endRequest(tracking);
|
addSpanEvent(context.span, 'avatar_exists_skip');
|
||||||
edgeLogger.info('Avatar already exists, skipping', { action: 'oauth_profile', requestId: tracking.requestId, duration });
|
return new Response(JSON.stringify({ success: true, message: 'Avatar already exists' }), {
|
||||||
return new Response(JSON.stringify({ success: true, message: 'Avatar already exists', requestId: tracking.requestId }), {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,26 +169,13 @@ Deno.serve(async (req) => {
|
|||||||
let cloudflareImageUrl: string | null = null;
|
let cloudflareImageUrl: string | null = null;
|
||||||
|
|
||||||
// Download and upload avatar to Cloudflare
|
// Download and upload avatar to Cloudflare
|
||||||
if (avatarUrl) {
|
if (avatarUrl && CLOUDFLARE_ACCOUNT_ID && CLOUDFLARE_API_TOKEN) {
|
||||||
// Validate secrets before attempting upload
|
try {
|
||||||
if (!CLOUDFLARE_ACCOUNT_ID || !CLOUDFLARE_API_TOKEN) {
|
addSpanEvent(context.span, 'avatar_download_start', { avatarUrl });
|
||||||
edgeLogger.warn('Cloudflare secrets not configured, skipping avatar upload', {
|
|
||||||
action: 'oauth_profile_upload',
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
edgeLogger.warn('Missing Cloudflare configuration', {
|
|
||||||
action: 'oauth_profile_upload',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
accountId: !CLOUDFLARE_ACCOUNT_ID,
|
|
||||||
apiToken: !CLOUDFLARE_API_TOKEN,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
edgeLogger.info('Downloading avatar', { action: 'oauth_profile_upload', avatarUrl, requestId: tracking.requestId });
|
|
||||||
|
|
||||||
// Download image with timeout
|
// Download image with timeout
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|
||||||
const imageResponse = await fetch(avatarUrl, {
|
const imageResponse = await fetch(avatarUrl, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
@@ -266,17 +188,11 @@ Deno.serve(async (req) => {
|
|||||||
|
|
||||||
const imageBlob = await imageResponse.blob();
|
const imageBlob = await imageResponse.blob();
|
||||||
|
|
||||||
// Validate image size (max 10MB)
|
|
||||||
if (imageBlob.size > 10 * 1024 * 1024) {
|
if (imageBlob.size > 10 * 1024 * 1024) {
|
||||||
throw new Error('Image too large (max 10MB)');
|
throw new Error('Image too large (max 10MB)');
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeLogger.info('Downloaded image', {
|
addSpanEvent(context.span, 'avatar_downloaded', { size: imageBlob.size });
|
||||||
action: 'oauth_profile_upload',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
size: imageBlob.size,
|
|
||||||
type: imageBlob.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get upload URL from Cloudflare
|
// Get upload URL from Cloudflare
|
||||||
const uploadUrlResponse = await fetch(
|
const uploadUrlResponse = await fetch(
|
||||||
@@ -296,8 +212,6 @@ Deno.serve(async (req) => {
|
|||||||
const uploadData = await uploadUrlResponse.json();
|
const uploadData = await uploadUrlResponse.json();
|
||||||
const uploadURL = uploadData.result.uploadURL;
|
const uploadURL = uploadData.result.uploadURL;
|
||||||
|
|
||||||
edgeLogger.info('Got Cloudflare upload URL', { action: 'oauth_profile_upload', requestId: tracking.requestId });
|
|
||||||
|
|
||||||
// Upload to Cloudflare
|
// Upload to Cloudflare
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', imageBlob, 'avatar.png');
|
formData.append('file', imageBlob, 'avatar.png');
|
||||||
@@ -316,22 +230,13 @@ Deno.serve(async (req) => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
cloudflareImageId = result.result.id;
|
cloudflareImageId = result.result.id;
|
||||||
cloudflareImageUrl = `https://cdn.thrillwiki.com/images/${cloudflareImageId}/avatar`;
|
cloudflareImageUrl = `https://cdn.thrillwiki.com/images/${cloudflareImageId}/avatar`;
|
||||||
edgeLogger.info('Uploaded to Cloudflare', { action: 'oauth_profile_upload', requestId: tracking.requestId, cloudflareImageId, cloudflareImageUrl });
|
addSpanEvent(context.span, 'avatar_uploaded', { imageId: cloudflareImageId });
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Cloudflare upload failed');
|
throw new Error('Cloudflare upload failed');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
edgeLogger.error('Avatar upload failed', {
|
addSpanEvent(context.span, 'avatar_upload_failed', { error: error.message });
|
||||||
action: 'oauth_profile_upload',
|
// Continue without avatar - don't block profile creation
|
||||||
requestId: tracking.requestId,
|
|
||||||
error: error.message,
|
|
||||||
provider: provider,
|
|
||||||
accountId: CLOUDFLARE_ACCOUNT_ID,
|
|
||||||
hasToken: !!CLOUDFLARE_API_TOKEN,
|
|
||||||
avatarUrl,
|
|
||||||
});
|
|
||||||
// Continue without avatar - don't block profile creation
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +256,7 @@ Deno.serve(async (req) => {
|
|||||||
if (usernameBase && profile?.username?.startsWith('user_')) {
|
if (usernameBase && profile?.username?.startsWith('user_')) {
|
||||||
const newUsername = await ensureUniqueUsername(supabase, usernameBase, user.id);
|
const newUsername = await ensureUniqueUsername(supabase, usernameBase, user.id);
|
||||||
updateData.username = newUsername;
|
updateData.username = newUsername;
|
||||||
edgeLogger.info('Updating generic username', { action: 'oauth_profile', requestId: tracking.requestId, oldUsername: profile.username, newUsername });
|
addSpanEvent(context.span, 'username_updated', { oldUsername: profile.username, newUsername });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update if we have data to update
|
// Only update if we have data to update
|
||||||
@@ -362,34 +267,18 @@ Deno.serve(async (req) => {
|
|||||||
.eq('user_id', user.id);
|
.eq('user_id', user.id);
|
||||||
|
|
||||||
if (updateError) {
|
if (updateError) {
|
||||||
edgeLogger.error('Failed to update profile', { action: 'oauth_profile', requestId: tracking.requestId, error: updateError });
|
throw new Error('Failed to update profile');
|
||||||
return new Response(JSON.stringify({ error: 'Failed to update profile' }), {
|
|
||||||
status: 500,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeLogger.info('Profile updated successfully', { action: 'oauth_profile', requestId: tracking.requestId });
|
addSpanEvent(context.span, 'profile_updated', { fieldsUpdated: Object.keys(updateData).length });
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.info('Processing complete', { action: 'oauth_profile', requestId: tracking.requestId, duration });
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
avatar_uploaded: !!cloudflareImageId,
|
avatar_uploaded: !!cloudflareImageId,
|
||||||
profile_updated: Object.keys(updateData).length > 0,
|
profile_updated: Object.keys(updateData).length > 0
|
||||||
requestId: tracking.requestId
|
|
||||||
}), {
|
}), {
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.error('Error in oauth profile processing', { action: 'oauth_profile', requestId: tracking.requestId, duration, error: error.message });
|
|
||||||
return new Response(JSON.stringify({ error: error.message, requestId: tracking.requestId }), {
|
|
||||||
status: 500,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
|
||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||||
import { corsHeaders } from '../_shared/cors.ts';
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
||||||
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
|
|
||||||
// Apply standard rate limiting (20 req/min) for account deletion requests
|
// Apply standard rate limiting (20 req/min) for account deletion requests
|
||||||
// Balances user needs with protection against automated abuse
|
const handler = createEdgeFunction(
|
||||||
serve(withRateLimit(async (req) => {
|
{
|
||||||
const tracking = startRequest();
|
name: 'request-account-deletion',
|
||||||
|
requireAuth: true,
|
||||||
if (req.method === 'OPTIONS') {
|
corsHeaders: corsHeaders
|
||||||
return new Response(null, {
|
},
|
||||||
headers: {
|
async (req, context) => {
|
||||||
...corsHeaders,
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const supabaseClient = createClient(
|
const supabaseClient = createClient(
|
||||||
Deno.env.get('SUPABASE_URL') ?? '',
|
Deno.env.get('SUPABASE_URL') ?? '',
|
||||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||||
@@ -29,33 +21,13 @@ serve(withRateLimit(async (req) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get authenticated user
|
context.span.setAttribute('action', 'request_deletion');
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
error: userError,
|
|
||||||
} = await supabaseClient.auth.getUser();
|
|
||||||
|
|
||||||
if (userError || !user) {
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.error('Authentication failed', {
|
|
||||||
action: 'request_deletion',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
duration
|
|
||||||
});
|
|
||||||
throw new Error('Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeLogger.info('Processing deletion request', {
|
|
||||||
action: 'request_deletion',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
userId: user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for existing active deletion request (pending or confirmed)
|
// Check for existing active deletion request (pending or confirmed)
|
||||||
const { data: existingRequest } = await supabaseClient
|
const { data: existingRequest } = await supabaseClient
|
||||||
.from('account_deletion_requests')
|
.from('account_deletion_requests')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', context.userId)
|
||||||
.in('status', ['pending', 'confirmed'])
|
.in('status', ['pending', 'confirmed'])
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@@ -71,11 +43,7 @@ serve(withRateLimit(async (req) => {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,7 +64,7 @@ serve(withRateLimit(async (req) => {
|
|||||||
const { data: deletionRequest, error: requestError } = await supabaseClient
|
const { data: deletionRequest, error: requestError } = await supabaseClient
|
||||||
.from('account_deletion_requests')
|
.from('account_deletion_requests')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: context.userId,
|
||||||
confirmation_code: confirmationCode,
|
confirmation_code: confirmationCode,
|
||||||
confirmation_code_sent_at: new Date().toISOString(),
|
confirmation_code_sent_at: new Date().toISOString(),
|
||||||
scheduled_deletion_at: scheduledDeletionAt.toISOString(),
|
scheduled_deletion_at: scheduledDeletionAt.toISOString(),
|
||||||
@@ -110,45 +78,11 @@ serve(withRateLimit(async (req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send confirmation email
|
// Send confirmation email
|
||||||
const emailPayload = {
|
const userEmail = (await supabaseClient.auth.getUser()).data.user?.email;
|
||||||
to: user.email,
|
|
||||||
subject: 'Account Deletion Requested - Confirmation Code Inside',
|
|
||||||
html: `
|
|
||||||
<h2>Account Deletion Requested</h2>
|
|
||||||
<p>Hello,</p>
|
|
||||||
<p>We received a request to delete your account on ${new Date().toLocaleDateString()}.</p>
|
|
||||||
|
|
||||||
<h3>IMPORTANT INFORMATION:</h3>
|
|
||||||
<p>You must enter the confirmation code within 24 hours. Once confirmed, your account will be deactivated and permanently deleted on <strong>${scheduledDeletionAt.toLocaleDateString()}</strong> (14 days from confirmation).</p>
|
|
||||||
|
|
||||||
<h4>What will be DELETED:</h4>
|
|
||||||
<ul>
|
|
||||||
<li>✗ Your profile information (username, bio, avatar, etc.)</li>
|
|
||||||
<li>✗ Your reviews and ratings</li>
|
|
||||||
<li>✗ Your personal preferences and settings</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h4>What will be PRESERVED:</h4>
|
|
||||||
<ul>
|
|
||||||
<li>✓ Your database submissions (park creations, ride additions, edits)</li>
|
|
||||||
<li>✓ Photos you've uploaded (will be shown as "Submitted by [deleted user]")</li>
|
|
||||||
<li>✓ Edit history and contributions</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>CONFIRMATION CODE: <strong>${confirmationCode}</strong></h3>
|
|
||||||
<p><strong>IMPORTANT:</strong> You have 24 hours to enter this code to confirm the deletion. After entering the code, your account will be deactivated and you'll have 14 days to cancel before permanent deletion.</p>
|
|
||||||
|
|
||||||
<p><strong>Need to cancel?</strong> You can cancel at any time - before OR after confirming - during the 14-day period.</p>
|
|
||||||
|
|
||||||
<p><strong>Changed your mind?</strong> Simply log in to your account settings and click "Cancel Deletion".</p>
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send via ForwardEmail API
|
|
||||||
const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY');
|
const forwardEmailKey = Deno.env.get('FORWARDEMAIL_API_KEY');
|
||||||
const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com';
|
const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com';
|
||||||
|
|
||||||
if (forwardEmailKey) {
|
if (forwardEmailKey && userEmail) {
|
||||||
try {
|
try {
|
||||||
await fetch('https://api.forwardemail.net/v1/emails', {
|
await fetch('https://api.forwardemail.net/v1/emails', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -158,67 +92,57 @@ serve(withRateLimit(async (req) => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
from: fromEmail,
|
from: fromEmail,
|
||||||
to: emailPayload.to,
|
to: userEmail,
|
||||||
subject: emailPayload.subject,
|
subject: 'Account Deletion Requested - Confirmation Code Inside',
|
||||||
html: emailPayload.html,
|
html: `
|
||||||
|
<h2>Account Deletion Requested</h2>
|
||||||
|
<p>Hello,</p>
|
||||||
|
<p>We received a request to delete your account on ${new Date().toLocaleDateString()}.</p>
|
||||||
|
|
||||||
|
<h3>IMPORTANT INFORMATION:</h3>
|
||||||
|
<p>You must enter the confirmation code within 24 hours. Once confirmed, your account will be deactivated and permanently deleted on <strong>${scheduledDeletionAt.toLocaleDateString()}</strong> (14 days from confirmation).</p>
|
||||||
|
|
||||||
|
<h4>What will be DELETED:</h4>
|
||||||
|
<ul>
|
||||||
|
<li>✗ Your profile information (username, bio, avatar, etc.)</li>
|
||||||
|
<li>✗ Your reviews and ratings</li>
|
||||||
|
<li>✗ Your personal preferences and settings</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>What will be PRESERVED:</h4>
|
||||||
|
<ul>
|
||||||
|
<li>✓ Your database submissions (park creations, ride additions, edits)</li>
|
||||||
|
<li>✓ Photos you've uploaded (will be shown as "Submitted by [deleted user]")</li>
|
||||||
|
<li>✓ Edit history and contributions</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>CONFIRMATION CODE: <strong>${confirmationCode}</strong></h3>
|
||||||
|
<p><strong>IMPORTANT:</strong> You have 24 hours to enter this code to confirm the deletion. After entering the code, your account will be deactivated and you'll have 14 days to cancel before permanent deletion.</p>
|
||||||
|
|
||||||
|
<p><strong>Need to cancel?</strong> You can cancel at any time - before OR after confirming - during the 14-day period.</p>
|
||||||
|
|
||||||
|
<p><strong>Changed your mind?</strong> Simply log in to your account settings and click "Cancel Deletion".</p>
|
||||||
|
`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
edgeLogger.info('Deletion confirmation email sent', { requestId: tracking.requestId });
|
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
edgeLogger.error('Failed to send email', {
|
// Non-blocking email failure
|
||||||
requestId: tracking.requestId,
|
|
||||||
error: emailError.message
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.info('Deletion request created successfully', {
|
|
||||||
action: 'request_deletion',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
userId: user.id,
|
|
||||||
duration,
|
|
||||||
requestId: deletionRequest.id
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Account deletion request created successfully',
|
message: 'Account deletion request created successfully',
|
||||||
scheduled_deletion_at: scheduledDeletionAt.toISOString(),
|
scheduled_deletion_at: scheduledDeletionAt.toISOString(),
|
||||||
request_id: deletionRequest.id,
|
request_id: deletionRequest.id,
|
||||||
requestId: tracking.requestId,
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.error('Error processing deletion request', {
|
|
||||||
action: 'request_deletion',
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
duration,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: error.message,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, rateLimiters.standard, corsHeaders));
|
);
|
||||||
|
|
||||||
|
export default withRateLimit(handler, rateLimiters.standard, corsHeaders);
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
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 { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
||||||
import { corsHeaders } from '../_shared/cors.ts';
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
||||||
import { edgeLogger } from "../_shared/logger.ts";
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { createErrorResponse } from "../_shared/errorSanitizer.ts";
|
import { validateString } from '../_shared/typeValidation.ts';
|
||||||
import { formatEdgeError } from "../_shared/errorFormatter.ts";
|
|
||||||
|
|
||||||
interface ContactSubmission {
|
interface ContactSubmission {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -15,78 +13,51 @@ interface ContactSubmission {
|
|||||||
captchaToken?: string;
|
captchaToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handler = async (req: Request): Promise<Response> => {
|
// Apply standard rate limiting (20 req/min) for contact form submissions
|
||||||
// Handle CORS preflight requests
|
const handler = createEdgeFunction(
|
||||||
if (req.method === 'OPTIONS') {
|
{
|
||||||
return new Response(null, { headers: corsHeaders });
|
name: 'send-contact-message',
|
||||||
}
|
requireAuth: false,
|
||||||
|
corsHeaders: corsHeaders
|
||||||
const requestId = crypto.randomUUID();
|
},
|
||||||
const startTime = Date.now();
|
async (req, context) => {
|
||||||
|
|
||||||
try {
|
|
||||||
// Parse request body
|
// Parse request body
|
||||||
const body: ContactSubmission = await req.json();
|
const body: ContactSubmission = await req.json();
|
||||||
const { name, email, subject, message, category, captchaToken } = body;
|
const { name, email, subject, message, category, captchaToken } = body;
|
||||||
|
|
||||||
edgeLogger.info('Contact form submission received', {
|
context.span.setAttribute('action', 'contact_submission');
|
||||||
requestId,
|
context.span.setAttribute('category', category);
|
||||||
email,
|
|
||||||
category
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields using shared utilities
|
||||||
if (!name || !email || !subject || !message || !category) {
|
validateString(name, 'name', { requestId: context.requestId });
|
||||||
return createErrorResponse(
|
validateString(email, 'email', { requestId: context.requestId });
|
||||||
{ message: 'Missing required fields' },
|
validateString(subject, 'subject', { requestId: context.requestId });
|
||||||
400,
|
validateString(message, 'message', { requestId: context.requestId });
|
||||||
corsHeaders
|
validateString(category, 'category', { requestId: context.requestId });
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate field lengths
|
// Validate field lengths
|
||||||
if (name.length < 2 || name.length > 100) {
|
if (name.length < 2 || name.length > 100) {
|
||||||
return createErrorResponse(
|
throw new Error('Name must be between 2 and 100 characters');
|
||||||
{ message: 'Name must be between 2 and 100 characters' },
|
|
||||||
400,
|
|
||||||
corsHeaders
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subject.length < 5 || subject.length > 200) {
|
if (subject.length < 5 || subject.length > 200) {
|
||||||
return createErrorResponse(
|
throw new Error('Subject must be between 5 and 200 characters');
|
||||||
{ message: 'Subject must be between 5 and 200 characters' },
|
|
||||||
400,
|
|
||||||
corsHeaders
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.length < 20 || message.length > 2000) {
|
if (message.length < 20 || message.length > 2000) {
|
||||||
return createErrorResponse(
|
throw new Error('Message must be between 20 and 2000 characters');
|
||||||
{ message: 'Message must be between 20 and 2000 characters' },
|
|
||||||
400,
|
|
||||||
corsHeaders
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate email format
|
// Validate email format
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!emailRegex.test(email)) {
|
if (!emailRegex.test(email)) {
|
||||||
return createErrorResponse(
|
throw new Error('Invalid email address');
|
||||||
{ message: 'Invalid email address' },
|
|
||||||
400,
|
|
||||||
corsHeaders
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate category
|
// Validate category
|
||||||
const validCategories = ['general', 'moderation', 'technical', 'account', 'partnership', 'report', 'other'];
|
const validCategories = ['general', 'moderation', 'technical', 'account', 'partnership', 'report', 'other'];
|
||||||
if (!validCategories.includes(category)) {
|
if (!validCategories.includes(category)) {
|
||||||
return createErrorResponse(
|
throw new Error('Invalid category');
|
||||||
{ message: 'Invalid category' },
|
|
||||||
400,
|
|
||||||
corsHeaders
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user agent and create IP hash
|
// Get user agent and create IP hash
|
||||||
@@ -111,64 +82,41 @@ const handler = async (req: Request): Promise<Response> => {
|
|||||||
.eq('email', email)
|
.eq('email', email)
|
||||||
.gte('created_at', oneHourAgo);
|
.gte('created_at', oneHourAgo);
|
||||||
|
|
||||||
if (rateLimitError) {
|
if (!rateLimitError && recentSubmissions && recentSubmissions.length >= 3) {
|
||||||
edgeLogger.error('Rate limit check failed', { requestId, error: rateLimitError.message });
|
return new Response(
|
||||||
} else if (recentSubmissions && recentSubmissions.length >= 3) {
|
JSON.stringify({ message: 'Too many submissions. Please wait an hour before submitting again.' }),
|
||||||
edgeLogger.warn('Rate limit exceeded', { requestId, email });
|
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
||||||
return createErrorResponse(
|
|
||||||
{ message: 'Too many submissions. Please wait an hour before submitting again.' },
|
|
||||||
429,
|
|
||||||
corsHeaders
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user ID and profile if authenticated
|
// Get user ID and profile if authenticated
|
||||||
const authHeader = req.headers.get('Authorization');
|
let userId: string | null = context.userId || null;
|
||||||
let userId: string | null = null;
|
|
||||||
let submitterUsername: string | null = null;
|
let submitterUsername: string | null = null;
|
||||||
let submitterReputation: number | null = null;
|
let submitterReputation: number | null = null;
|
||||||
let submitterProfileData: Record<string, unknown> | null = null;
|
let submitterProfileData: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
if (authHeader) {
|
if (userId) {
|
||||||
const supabaseClient = createClient(
|
const { data: profile } = await supabase
|
||||||
supabaseUrl,
|
.from('profiles')
|
||||||
Deno.env.get('SUPABASE_ANON_KEY')!,
|
.select('username, display_name, reputation_score, ride_count, coaster_count, park_count, review_count, created_at, avatar_url')
|
||||||
{ global: { headers: { Authorization: authHeader } } }
|
.eq('user_id', userId)
|
||||||
);
|
.single();
|
||||||
|
|
||||||
const { data: { user } } = await supabaseClient.auth.getUser();
|
if (profile) {
|
||||||
userId = user?.id || null;
|
submitterUsername = profile.username;
|
||||||
|
submitterReputation = profile.reputation_score || 0;
|
||||||
// Fetch user profile for enhanced context
|
submitterProfileData = {
|
||||||
if (userId) {
|
display_name: profile.display_name,
|
||||||
const { data: profile } = await supabase
|
member_since: profile.created_at,
|
||||||
.from('profiles')
|
stats: {
|
||||||
.select('username, display_name, reputation_score, ride_count, coaster_count, park_count, review_count, created_at, avatar_url')
|
rides: profile.ride_count || 0,
|
||||||
.eq('user_id', userId)
|
coasters: profile.coaster_count || 0,
|
||||||
.single();
|
parks: profile.park_count || 0,
|
||||||
|
reviews: profile.review_count || 0,
|
||||||
if (profile) {
|
},
|
||||||
submitterUsername = profile.username;
|
reputation: profile.reputation_score || 0,
|
||||||
submitterReputation = profile.reputation_score || 0;
|
avatar_url: profile.avatar_url
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,18 +141,9 @@ const handler = async (req: Request): Promise<Response> => {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (insertError) {
|
if (insertError) {
|
||||||
edgeLogger.error('Failed to insert contact submission', {
|
throw insertError;
|
||||||
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)
|
// Send notification email to admin (async, don't wait)
|
||||||
const adminEmail = Deno.env.get('ADMIN_EMAIL_ADDRESS') || 'admin@thrillwiki.com';
|
const adminEmail = Deno.env.get('ADMIN_EMAIL_ADDRESS') || 'admin@thrillwiki.com';
|
||||||
const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com';
|
const fromEmail = Deno.env.get('FROM_EMAIL_ADDRESS') || 'noreply@thrillwiki.com';
|
||||||
@@ -230,7 +169,7 @@ const handler = async (req: Request): Promise<Response> => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update thread_id with Message-ID format (always, not just when email is sent)
|
// Update thread_id with Message-ID format
|
||||||
const threadId = `${ticketNumber}.${submission.id}`;
|
const threadId = `${ticketNumber}.${submission.id}`;
|
||||||
await supabase
|
await supabase
|
||||||
.from('contact_submissions')
|
.from('contact_submissions')
|
||||||
@@ -268,8 +207,8 @@ View in admin panel: https://thrillwiki.com/admin/contact`,
|
|||||||
'X-Ticket-Number': ticketNumber
|
'X-Ticket-Number': ticketNumber
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}).catch(err => {
|
}).catch(() => {
|
||||||
edgeLogger.error('Failed to send admin notification', { requestId, error: err.message });
|
// Non-blocking email failure
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send user confirmation email
|
// Send user confirmation email
|
||||||
@@ -304,18 +243,11 @@ The ThrillWiki Team`,
|
|||||||
'References': messageId
|
'References': messageId
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}).catch(err => {
|
}).catch(() => {
|
||||||
edgeLogger.error('Failed to send confirmation email', { requestId, error: err.message });
|
// Non-blocking email failure
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
edgeLogger.info('Contact submission processed successfully', {
|
|
||||||
requestId,
|
|
||||||
duration,
|
|
||||||
submissionId: submission.id
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -325,20 +257,10 @@ The ThrillWiki Team`,
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} 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
|
export default withRateLimit(handler, rateLimiters.standard, corsHeaders);
|
||||||
// Balances legitimate user needs with spam prevention
|
|
||||||
serve(withRateLimit(handler, rateLimiters.standard, corsHeaders));
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
|
|
||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||||
import { getAllowedOrigin, getCorsHeaders } from '../_shared/cors.ts'
|
import { getAllowedOrigin, getCorsHeaders } from '../_shared/cors.ts'
|
||||||
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts'
|
|
||||||
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts'
|
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts'
|
||||||
import { formatEdgeError } from '../_shared/errorFormatter.ts'
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts'
|
||||||
|
import { validateString } from '../_shared/typeValidation.ts'
|
||||||
|
import { addSpanEvent } from '../_shared/logger.ts'
|
||||||
|
|
||||||
// Helper to create authenticated Supabase client
|
// Helper to create authenticated Supabase client
|
||||||
const createAuthenticatedSupabaseClient = (authHeader: string) => {
|
const createAuthenticatedSupabaseClient = (authHeader: string) => {
|
||||||
@@ -42,45 +42,39 @@ async function reportBanEvasionToAlerts(
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Non-blocking - log but don't fail the response
|
// Non-blocking - log but don't fail the response
|
||||||
edgeLogger.warn('Failed to report ban evasion', {
|
|
||||||
error: formatEdgeError(error),
|
|
||||||
requestId
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply strict rate limiting (5 requests/minute) to prevent abuse
|
// Apply strict rate limiting (5 requests/minute) to prevent abuse
|
||||||
const uploadRateLimiter = rateLimiters.strict;
|
const handler = createEdgeFunction(
|
||||||
|
{
|
||||||
|
name: 'upload-image',
|
||||||
|
requireAuth: false, // Auth checked per-method
|
||||||
|
corsHeaders: {} // Dynamic CORS
|
||||||
|
},
|
||||||
|
async (req, context) => {
|
||||||
|
const requestOrigin = req.headers.get('origin');
|
||||||
|
const allowedOrigin = getAllowedOrigin(requestOrigin);
|
||||||
|
|
||||||
serve(withRateLimit(async (req) => {
|
// Check if this is a CORS request with a disallowed origin
|
||||||
const tracking = startRequest();
|
if (requestOrigin && !allowedOrigin) {
|
||||||
const requestOrigin = req.headers.get('origin');
|
addSpanEvent(context.span, 'cors_rejected', { origin: requestOrigin });
|
||||||
const allowedOrigin = getAllowedOrigin(requestOrigin);
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Origin not allowed',
|
||||||
|
message: 'The origin of this request is not allowed to access this resource'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this is a CORS request with a disallowed origin
|
const corsHeaders = getCorsHeaders(allowedOrigin);
|
||||||
if (requestOrigin && !allowedOrigin) {
|
context.span.setAttribute('http_method', req.method);
|
||||||
edgeLogger.warn('CORS request rejected', { action: 'cors_validation', origin: requestOrigin, requestId: tracking.requestId });
|
context.span.setAttribute('action', 'upload_image');
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Origin not allowed',
|
|
||||||
message: 'The origin of this request is not allowed to access this resource'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define CORS headers at function scope so they're available in catch block
|
|
||||||
const corsHeaders = getCorsHeaders(allowedOrigin);
|
|
||||||
|
|
||||||
// Handle CORS preflight requests
|
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
return new Response(null, { headers: corsHeaders })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const CLOUDFLARE_ACCOUNT_ID = Deno.env.get('CLOUDFLARE_ACCOUNT_ID')
|
const CLOUDFLARE_ACCOUNT_ID = Deno.env.get('CLOUDFLARE_ACCOUNT_ID')
|
||||||
const CLOUDFLARE_IMAGES_API_TOKEN = Deno.env.get('CLOUDFLARE_IMAGES_API_TOKEN')
|
const CLOUDFLARE_IMAGES_API_TOKEN = Deno.env.get('CLOUDFLARE_IMAGES_API_TOKEN')
|
||||||
|
|
||||||
@@ -104,200 +98,72 @@ serve(withRateLimit(async (req) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify JWT token
|
|
||||||
const supabase = createAuthenticatedSupabaseClient(authHeader)
|
const supabase = createAuthenticatedSupabaseClient(authHeader)
|
||||||
|
|
||||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||||
if (authError || !user) {
|
if (authError || !user) {
|
||||||
edgeLogger.error('Auth verification failed', { action: 'delete_auth', error: authError?.message })
|
throw new Error('Invalid authentication');
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Invalid authentication',
|
|
||||||
message: 'Authentication token is invalid or expired'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.span.setAttribute('user_id', user.id);
|
||||||
|
|
||||||
// Check if user is banned
|
// Check if user is banned
|
||||||
const { data: profile, error: profileError } = await supabase
|
const { data: profile } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('banned')
|
.select('banned')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (profileError || !profile) {
|
if (profile?.banned) {
|
||||||
edgeLogger.error('Failed to fetch user profile', { action: 'delete_profile_check', userId: user.id })
|
await reportBanEvasionToAlerts(supabase, user.id, 'image_delete', context.requestId);
|
||||||
return new Response(
|
addSpanEvent(context.span, 'banned_user_blocked', { action: 'delete' });
|
||||||
JSON.stringify({
|
|
||||||
error: 'User profile not found',
|
|
||||||
message: 'Unable to verify user profile'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profile.banned) {
|
|
||||||
// Report ban evasion attempt (non-blocking)
|
|
||||||
await reportBanEvasionToAlerts(supabase, user.id, 'image_delete', tracking.requestId);
|
|
||||||
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.warn('Banned user blocked from image deletion', {
|
|
||||||
userId: user.id,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'Account suspended',
|
error: 'Account suspended',
|
||||||
message: 'Account suspended. Contact support for assistance.',
|
message: 'Account suspended. Contact support for assistance.'
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 403,
|
status: 403,
|
||||||
headers: {
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete image from Cloudflare
|
// Delete image from Cloudflare
|
||||||
edgeLogger.info('Deleting image', { action: 'delete_image', requestId: tracking.requestId });
|
const requestBody = await req.json();
|
||||||
let requestBody;
|
|
||||||
try {
|
|
||||||
requestBody = await req.json();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const errorMessage = formatEdgeError(error);
|
|
||||||
edgeLogger.error('Invalid JSON in delete request', {
|
|
||||||
error: errorMessage,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Invalid JSON',
|
|
||||||
message: 'Request body must be valid JSON'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { imageId } = requestBody;
|
const { imageId } = requestBody;
|
||||||
|
|
||||||
if (!imageId || typeof imageId !== 'string' || imageId.trim() === '') {
|
validateString(imageId, 'imageId', { userId: user.id, requestId: context.requestId });
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Invalid imageId',
|
|
||||||
message: 'imageId is required and must be a non-empty string'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate imageId format - Cloudflare accepts UUIDs and alphanumeric IDs
|
// Validate imageId format
|
||||||
// Allow: alphanumeric, hyphens, underscores (common ID formats)
|
|
||||||
// Reject: special characters that could cause injection or path traversal
|
|
||||||
const validImageIdPattern = /^[a-zA-Z0-9_-]{1,100}$/;
|
const validImageIdPattern = /^[a-zA-Z0-9_-]{1,100}$/;
|
||||||
if (!validImageIdPattern.test(imageId)) {
|
if (!validImageIdPattern.test(imageId)) {
|
||||||
return new Response(
|
throw new Error('Invalid imageId format - must be alphanumeric with optional hyphens/underscores (max 100 chars)');
|
||||||
JSON.stringify({
|
|
||||||
error: 'Invalid imageId format',
|
|
||||||
message: 'imageId must be alphanumeric with optional hyphens/underscores (max 100 chars)'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let deleteResponse;
|
addSpanEvent(context.span, 'delete_image_start', { imageId });
|
||||||
try {
|
|
||||||
deleteResponse = await fetch(
|
|
||||||
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v1/${imageId}`,
|
|
||||||
{
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${CLOUDFLARE_IMAGES_API_TOKEN}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (fetchError) {
|
|
||||||
edgeLogger.error('Network error deleting image', {
|
|
||||||
error: String(fetchError),
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Network error',
|
|
||||||
message: 'Unable to reach Cloudflare Images API'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let deleteResult;
|
const deleteResponse = await fetch(
|
||||||
try {
|
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v1/${imageId}`,
|
||||||
deleteResult = await deleteResponse.json()
|
{
|
||||||
} catch (parseError) {
|
method: 'DELETE',
|
||||||
edgeLogger.error('Failed to parse Cloudflare delete response', {
|
headers: {
|
||||||
error: String(parseError),
|
'Authorization': `Bearer ${CLOUDFLARE_IMAGES_API_TOKEN}`,
|
||||||
requestId: tracking.requestId
|
},
|
||||||
});
|
}
|
||||||
return new Response(
|
)
|
||||||
JSON.stringify({
|
|
||||||
error: 'Invalid response',
|
const deleteResult = await deleteResponse.json()
|
||||||
message: 'Unable to parse response from Cloudflare Images API'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!deleteResponse.ok) {
|
if (!deleteResponse.ok) {
|
||||||
edgeLogger.error('Cloudflare delete error', {
|
throw new Error(deleteResult.errors?.[0]?.message || deleteResult.error || 'Failed to delete image');
|
||||||
result: deleteResult,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Failed to delete image',
|
|
||||||
message: deleteResult.errors?.[0]?.message || deleteResult.error || 'Unknown error occurred',
|
|
||||||
details: deleteResult.errors || deleteResult.error
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = endRequest(tracking);
|
addSpanEvent(context.span, 'image_deleted', { imageId });
|
||||||
edgeLogger.info('Image deleted successfully', { action: 'delete_image', requestId: tracking.requestId, duration });
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ success: true, deleted: true, requestId: tracking.requestId }),
|
JSON.stringify({ success: true, deleted: true }),
|
||||||
{
|
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,43 +183,30 @@ serve(withRateLimit(async (req) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify JWT token
|
|
||||||
const supabase = createAuthenticatedSupabaseClient(authHeader)
|
const supabase = createAuthenticatedSupabaseClient(authHeader)
|
||||||
|
|
||||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||||
if (authError || !user) {
|
if (authError || !user) {
|
||||||
edgeLogger.error('Auth verification failed for POST', {
|
throw new Error('Invalid authentication');
|
||||||
error: authError?.message,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Invalid authentication',
|
|
||||||
message: 'Authentication token is invalid or expired'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.span.setAttribute('user_id', user.id);
|
||||||
|
|
||||||
// Check if user is banned
|
// Check if user is banned
|
||||||
const { data: profile, error: profileError } = await supabase
|
const { data: profile } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('banned')
|
.select('banned')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (profileError || !profile) {
|
if (profile?.banned) {
|
||||||
edgeLogger.error('Failed to fetch user profile for POST', {
|
await reportBanEvasionToAlerts(supabase, user.id, 'image_upload', context.requestId);
|
||||||
error: profileError?.message,
|
addSpanEvent(context.span, 'banned_user_blocked', { action: 'upload' });
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'User profile not found',
|
error: 'Account suspended',
|
||||||
message: 'Unable to verify user profile'
|
message: 'Account suspended. Contact support for assistance.'
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 403,
|
status: 403,
|
||||||
@@ -362,31 +215,7 @@ serve(withRateLimit(async (req) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profile.banned) {
|
|
||||||
// Report ban evasion attempt (non-blocking)
|
|
||||||
await reportBanEvasionToAlerts(supabase, user.id, 'image_upload', tracking.requestId);
|
|
||||||
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.warn('Banned user blocked from image upload', {
|
|
||||||
userId: user.id,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Account suspended',
|
|
||||||
message: 'Account suspended. Contact support for assistance.',
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request a direct upload URL from Cloudflare
|
// Request a direct upload URL from Cloudflare
|
||||||
edgeLogger.info('Requesting upload URL', { action: 'request_upload_url', requestId: tracking.requestId });
|
|
||||||
let requestBody;
|
let requestBody;
|
||||||
try {
|
try {
|
||||||
requestBody = await req.json();
|
requestBody = await req.json();
|
||||||
@@ -394,111 +223,44 @@ serve(withRateLimit(async (req) => {
|
|||||||
requestBody = {};
|
requestBody = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate request body structure
|
|
||||||
if (requestBody && typeof requestBody !== 'object') {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Invalid request body',
|
|
||||||
message: 'Request body must be a valid JSON object'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { metadata = {}, variant = 'public', requireSignedURLs = false } = requestBody;
|
const { metadata = {}, variant = 'public', requireSignedURLs = false } = requestBody;
|
||||||
|
|
||||||
// Create FormData for the request (Cloudflare API requires multipart/form-data)
|
addSpanEvent(context.span, 'upload_url_request_start');
|
||||||
|
|
||||||
|
// Create FormData for the request
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('requireSignedURLs', requireSignedURLs.toString())
|
formData.append('requireSignedURLs', requireSignedURLs.toString())
|
||||||
|
|
||||||
// Add metadata to the request if provided
|
|
||||||
if (metadata && Object.keys(metadata).length > 0) {
|
if (metadata && Object.keys(metadata).length > 0) {
|
||||||
formData.append('metadata', JSON.stringify(metadata))
|
formData.append('metadata', JSON.stringify(metadata))
|
||||||
}
|
}
|
||||||
|
|
||||||
let directUploadResponse;
|
const directUploadResponse = await fetch(
|
||||||
try {
|
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v2/direct_upload`,
|
||||||
directUploadResponse = await fetch(
|
{
|
||||||
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v2/direct_upload`,
|
method: 'POST',
|
||||||
{
|
headers: {
|
||||||
method: 'POST',
|
'Authorization': `Bearer ${CLOUDFLARE_IMAGES_API_TOKEN}`,
|
||||||
headers: {
|
},
|
||||||
'Authorization': `Bearer ${CLOUDFLARE_IMAGES_API_TOKEN}`,
|
body: formData,
|
||||||
},
|
}
|
||||||
body: formData,
|
)
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (fetchError) {
|
|
||||||
edgeLogger.error('Network error getting upload URL', {
|
|
||||||
error: String(fetchError),
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Network error',
|
|
||||||
message: 'Unable to reach Cloudflare Images API'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let directUploadResult;
|
const directUploadResult = await directUploadResponse.json()
|
||||||
try {
|
|
||||||
directUploadResult = await directUploadResponse.json()
|
|
||||||
} catch (parseError) {
|
|
||||||
edgeLogger.error('Failed to parse Cloudflare upload response', {
|
|
||||||
error: String(parseError),
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Invalid response',
|
|
||||||
message: 'Unable to parse response from Cloudflare Images API'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!directUploadResponse.ok) {
|
if (!directUploadResponse.ok) {
|
||||||
edgeLogger.error('Cloudflare direct upload error', {
|
throw new Error(directUploadResult.errors?.[0]?.message || directUploadResult.error || 'Unable to create upload URL');
|
||||||
result: directUploadResult,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Failed to get upload URL',
|
|
||||||
message: directUploadResult.errors?.[0]?.message || directUploadResult.error || 'Unable to create upload URL',
|
|
||||||
details: directUploadResult.errors || directUploadResult.error
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the upload URL and image ID to the client
|
addSpanEvent(context.span, 'upload_url_created', { imageId: directUploadResult.result.id });
|
||||||
const duration = endRequest(tracking);
|
|
||||||
edgeLogger.info('Upload URL created', { action: 'upload_url_success', requestId: tracking.requestId, duration });
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
uploadURL: directUploadResult.result.uploadURL,
|
uploadURL: directUploadResult.result.uploadURL,
|
||||||
id: directUploadResult.result.id,
|
id: directUploadResult.result.id,
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
}),
|
||||||
{
|
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,117 +280,43 @@ serve(withRateLimit(async (req) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify JWT token
|
|
||||||
const supabase = createAuthenticatedSupabaseClient(authHeader)
|
const supabase = createAuthenticatedSupabaseClient(authHeader)
|
||||||
|
|
||||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||||
if (authError || !user) {
|
if (authError || !user) {
|
||||||
edgeLogger.error('Auth verification failed for GET', {
|
throw new Error('Invalid authentication');
|
||||||
error: authError?.message,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Invalid authentication',
|
|
||||||
message: 'Authentication token is invalid or expired'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.span.setAttribute('user_id', user.id);
|
||||||
|
|
||||||
// Check image status endpoint
|
// Check image status endpoint
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
const imageId = url.searchParams.get('id')
|
const imageId = url.searchParams.get('id')
|
||||||
|
|
||||||
if (!imageId || imageId.trim() === '') {
|
validateString(imageId, 'id', { userId: user.id, requestId: context.requestId });
|
||||||
return new Response(
|
|
||||||
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' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let imageResponse;
|
addSpanEvent(context.span, 'get_image_status_start', { imageId });
|
||||||
try {
|
|
||||||
imageResponse = await fetch(
|
|
||||||
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v1/${imageId}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${CLOUDFLARE_IMAGES_API_TOKEN}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (fetchError) {
|
|
||||||
edgeLogger.error('Network error fetching image status', {
|
|
||||||
error: String(fetchError),
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Network error',
|
|
||||||
message: 'Unable to reach Cloudflare Images API'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let imageResult;
|
const imageResponse = await fetch(
|
||||||
try {
|
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v1/${imageId}`,
|
||||||
imageResult = await imageResponse.json()
|
{
|
||||||
} catch (parseError) {
|
headers: {
|
||||||
edgeLogger.error('Failed to parse Cloudflare image status response', {
|
'Authorization': `Bearer ${CLOUDFLARE_IMAGES_API_TOKEN}`,
|
||||||
error: String(parseError),
|
},
|
||||||
requestId: tracking.requestId
|
}
|
||||||
});
|
)
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
const imageResult = await imageResponse.json()
|
||||||
error: 'Invalid response',
|
|
||||||
message: 'Unable to parse response from Cloudflare Images API'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!imageResponse.ok) {
|
if (!imageResponse.ok) {
|
||||||
edgeLogger.error('Cloudflare image status error', {
|
throw new Error(imageResult.errors?.[0]?.message || imageResult.error || 'Unable to retrieve image information');
|
||||||
result: imageResult,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Failed to get image status',
|
|
||||||
message: imageResult.errors?.[0]?.message || imageResult.error || 'Unable to retrieve image information',
|
|
||||||
details: imageResult.errors || imageResult.error
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the image details with convenient URLs
|
// Return the image details with convenient URLs
|
||||||
const result = imageResult.result
|
const result = imageResult.result
|
||||||
const duration = endRequest(tracking);
|
|
||||||
|
|
||||||
// Construct CDN URLs for display
|
|
||||||
const baseUrl = `https://cdn.thrillwiki.com/images/${result.id}`
|
const baseUrl = `https://cdn.thrillwiki.com/images/${result.id}`
|
||||||
|
|
||||||
edgeLogger.info('Image status retrieved', { action: 'get_image_status', requestId: tracking.requestId, duration });
|
addSpanEvent(context.span, 'image_status_retrieved', { imageId: result.id });
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -637,49 +325,29 @@ serve(withRateLimit(async (req) => {
|
|||||||
uploaded: result.uploaded,
|
uploaded: result.uploaded,
|
||||||
variants: result.variants,
|
variants: result.variants,
|
||||||
draft: result.draft,
|
draft: result.draft,
|
||||||
// Provide convenient URLs using proper Cloudflare Images format
|
|
||||||
urls: result.uploaded ? {
|
urls: result.uploaded ? {
|
||||||
public: `${baseUrl}/public`,
|
public: `${baseUrl}/public`,
|
||||||
thumbnail: `${baseUrl}/thumbnail`,
|
thumbnail: `${baseUrl}/thumbnail`,
|
||||||
medium: `${baseUrl}/medium`,
|
medium: `${baseUrl}/medium`,
|
||||||
large: `${baseUrl}/large`,
|
large: `${baseUrl}/large`,
|
||||||
avatar: `${baseUrl}/avatar`,
|
avatar: `${baseUrl}/avatar`,
|
||||||
} : null,
|
} : null
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
}),
|
||||||
{
|
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'Method not allowed',
|
error: 'Method not allowed',
|
||||||
message: 'HTTP method not supported for this endpoint',
|
message: 'HTTP method not supported for this endpoint'
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 405,
|
status: 405,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred';
|
|
||||||
edgeLogger.error('Upload function error', { action: 'upload_error', requestId: tracking.requestId, duration, error: errorMessage });
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Internal server error',
|
|
||||||
message: errorMessage,
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, uploadRateLimiter, getCorsHeaders(allowedOrigin)));
|
);
|
||||||
|
|
||||||
|
export default withRateLimit(handler, rateLimiters.strict, {} as any);
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
|
||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3';
|
|
||||||
import { corsHeaders } from '../_shared/cors.ts';
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts';
|
||||||
import { edgeLogger } from "../_shared/logger.ts";
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
import { formatEdgeError } from "../_shared/errorFormatter.ts";
|
import { validateString } from '../_shared/typeValidation.ts';
|
||||||
|
|
||||||
// Simple request tracking
|
|
||||||
const startRequest = () => ({ requestId: crypto.randomUUID(), start: Date.now() });
|
|
||||||
const endRequest = (tracking: { start: number }) => Date.now() - tracking.start;
|
|
||||||
|
|
||||||
// Common disposable email domains (subset for performance)
|
// Common disposable email domains (subset for performance)
|
||||||
const DISPOSABLE_DOMAINS = new Set([
|
const DISPOSABLE_DOMAINS = new Set([
|
||||||
@@ -53,69 +47,29 @@ function validateEmailFormat(email: string): EmailValidationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply lenient rate limiting (30 req/min) for email validation
|
// Apply lenient rate limiting (30 req/min) for email validation
|
||||||
// Users may need to validate multiple times during signup/profile update
|
const handler = createEdgeFunction(
|
||||||
serve(withRateLimit(async (req) => {
|
{
|
||||||
const tracking = startRequest();
|
name: 'validate-email-backend',
|
||||||
|
requireAuth: false,
|
||||||
// Handle CORS preflight requests
|
corsHeaders: corsHeaders
|
||||||
if (req.method === 'OPTIONS') {
|
},
|
||||||
return new Response(null, { headers: corsHeaders });
|
async (req, context) => {
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { email } = await req.json();
|
const { email } = await req.json();
|
||||||
|
validateString(email, 'email', { requestId: context.requestId });
|
||||||
|
|
||||||
if (!email || typeof email !== 'string') {
|
context.span.setAttribute('action', 'validate_email');
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ valid: false, reason: 'Email is required', requestId: tracking.requestId }),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate email
|
// Validate email
|
||||||
const result = validateEmailFormat(email.toLowerCase().trim());
|
const result = validateEmailFormat(email.toLowerCase().trim());
|
||||||
const duration = endRequest(tracking);
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ ...result, requestId: tracking.requestId }),
|
JSON.stringify(result),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' }
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const duration = endRequest(tracking);
|
|
||||||
const errorMessage = formatEdgeError(error);
|
|
||||||
edgeLogger.error('Email validation error', {
|
|
||||||
error: errorMessage,
|
|
||||||
requestId: tracking.requestId,
|
|
||||||
duration
|
|
||||||
});
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
valid: false,
|
|
||||||
reason: 'Failed to validate email. Please try again.',
|
|
||||||
requestId: tracking.requestId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Request-ID': tracking.requestId
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, rateLimiters.lenient, corsHeaders));
|
);
|
||||||
|
|
||||||
|
export default withRateLimit(handler, rateLimiters.lenient, corsHeaders);
|
||||||
|
|||||||
Reference in New Issue
Block a user