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:
gpt-engineer-app[bot]
2025-11-11 03:03:26 +00:00
parent 7181fdbcac
commit e28dc97d71
8 changed files with 394 additions and 1471 deletions

View File

@@ -1,23 +1,12 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
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_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 {
email?: string;
name?: string;
@@ -27,15 +16,15 @@ interface GoogleUserMetadata {
interface DiscordUserMetadata {
email?: string;
name?: string; // "username#0" format
full_name?: string; // "username" without discriminator
name?: string;
full_name?: string;
custom_claims?: {
global_name?: string; // Display name like "PacNPal"
global_name?: string;
};
avatar_url?: string; // Full CDN URL
picture?: string; // Alternative full CDN URL
provider_id?: string; // Discord user ID
sub?: string; // Alternative Discord user ID
avatar_url?: string;
picture?: string;
provider_id?: string;
sub?: string;
email_verified?: boolean;
phone_verified?: boolean;
iss?: string;
@@ -70,39 +59,29 @@ async function ensureUniqueUsername(
return `user_${userId.substring(0, 8)}`;
}
Deno.serve(async (req) => {
const tracking = startRequest();
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
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' },
});
}
export default createEdgeFunction(
{
name: 'process-oauth-profile',
requireAuth: true,
corsHeaders: corsHeaders
},
async (req, context) => {
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseKey);
context.span.setAttribute('action', 'oauth_profile');
context.span.setAttribute('user_id', context.userId);
// 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);
if (authError || !user) {
edgeLogger.error('Authentication failed', { action: 'oauth_profile', error: authError, requestId: tracking.requestId });
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
throw new Error('Unauthorized');
}
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
const { data: banProfile } = await supabase
@@ -112,30 +91,24 @@ Deno.serve(async (req) => {
.single();
if (banProfile?.banned) {
const duration = endRequest(tracking);
const message = banProfile.ban_reason
? `Your account has been suspended. Reason: ${banProfile.ban_reason}`
: 'Your account has been suspended. Contact support for assistance.';
edgeLogger.info('User is banned, rejecting authentication', {
action: 'oauth_profile_banned',
requestId: tracking.requestId,
duration,
hasBanReason: !!banProfile.ban_reason
});
addSpanEvent(context.span, 'user_banned', { hasBanReason: !!banProfile.ban_reason });
return new Response(JSON.stringify({
error: 'Account suspended',
message,
ban_reason: banProfile.ban_reason,
requestId: tracking.requestId
ban_reason: banProfile.ban_reason
}), {
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId },
headers: { 'Content-Type': 'application/json' },
});
}
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
let userMetadata = user.user_metadata;
@@ -143,18 +116,6 @@ Deno.serve(async (req) => {
const discordIdentity = user.identities.find(i => i.provider === 'discord');
if (discordIdentity) {
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;
displayName = googleData.name || 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') {
const discordData = userMetadata as DiscordUserMetadata;
// Extract Discord user ID from provider_id or sub
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;
// Extract username base: full_name or name without discriminator
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;
// 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) {
edgeLogger.warn('Discord username missing - using ID as fallback', { action: 'oauth_profile_discord', requestId: tracking.requestId });
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 {
edgeLogger.info('Unsupported provider', { action: 'oauth_profile', provider, requestId: tracking.requestId });
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
const { data: profile } = await supabase
.from('profiles')
@@ -223,10 +159,9 @@ Deno.serve(async (req) => {
.single();
if (profile?.avatar_image_id) {
const duration = endRequest(tracking);
edgeLogger.info('Avatar already exists, skipping', { action: 'oauth_profile', requestId: tracking.requestId, duration });
return new Response(JSON.stringify({ success: true, message: 'Avatar already exists', requestId: tracking.requestId }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId },
addSpanEvent(context.span, 'avatar_exists_skip');
return new Response(JSON.stringify({ success: true, message: 'Avatar already exists' }), {
headers: { 'Content-Type': 'application/json' },
});
}
@@ -234,26 +169,13 @@ Deno.serve(async (req) => {
let cloudflareImageUrl: string | null = null;
// Download and upload avatar to Cloudflare
if (avatarUrl) {
// Validate secrets before attempting upload
if (!CLOUDFLARE_ACCOUNT_ID || !CLOUDFLARE_API_TOKEN) {
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 });
if (avatarUrl && CLOUDFLARE_ACCOUNT_ID && CLOUDFLARE_API_TOKEN) {
try {
addSpanEvent(context.span, 'avatar_download_start', { avatarUrl });
// Download image with timeout
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout
const timeout = setTimeout(() => controller.abort(), 10000);
const imageResponse = await fetch(avatarUrl, {
signal: controller.signal,
@@ -266,17 +188,11 @@ Deno.serve(async (req) => {
const imageBlob = await imageResponse.blob();
// Validate image size (max 10MB)
if (imageBlob.size > 10 * 1024 * 1024) {
throw new Error('Image too large (max 10MB)');
}
edgeLogger.info('Downloaded image', {
action: 'oauth_profile_upload',
requestId: tracking.requestId,
size: imageBlob.size,
type: imageBlob.type,
});
addSpanEvent(context.span, 'avatar_downloaded', { size: imageBlob.size });
// Get upload URL from Cloudflare
const uploadUrlResponse = await fetch(
@@ -296,8 +212,6 @@ Deno.serve(async (req) => {
const uploadData = await uploadUrlResponse.json();
const uploadURL = uploadData.result.uploadURL;
edgeLogger.info('Got Cloudflare upload URL', { action: 'oauth_profile_upload', requestId: tracking.requestId });
// Upload to Cloudflare
const formData = new FormData();
formData.append('file', imageBlob, 'avatar.png');
@@ -316,22 +230,13 @@ Deno.serve(async (req) => {
if (result.success) {
cloudflareImageId = result.result.id;
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 {
throw new Error('Cloudflare upload failed');
}
} catch (error) {
edgeLogger.error('Avatar upload failed', {
action: 'oauth_profile_upload',
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
}
} catch (error) {
addSpanEvent(context.span, 'avatar_upload_failed', { error: error.message });
// Continue without avatar - don't block profile creation
}
}
@@ -351,7 +256,7 @@ Deno.serve(async (req) => {
if (usernameBase && profile?.username?.startsWith('user_')) {
const newUsername = await ensureUniqueUsername(supabase, usernameBase, user.id);
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
@@ -362,34 +267,18 @@ Deno.serve(async (req) => {
.eq('user_id', user.id);
if (updateError) {
edgeLogger.error('Failed to update profile', { action: 'oauth_profile', requestId: tracking.requestId, error: updateError });
return new Response(JSON.stringify({ error: 'Failed to update profile' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
throw new Error('Failed to update profile');
}
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({
success: true,
avatar_uploaded: !!cloudflareImageId,
profile_updated: Object.keys(updateData).length > 0,
requestId: tracking.requestId
profile_updated: Object.keys(updateData).length > 0
}), {
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId },
});
} 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 },
headers: { 'Content-Type': 'application/json' },
});
}
});
);