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 { 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'); interface GoogleUserMetadata { email?: string; name?: string; picture?: string; email_verified?: boolean; } interface DiscordUserMetadata { email?: string; name?: string; full_name?: string; custom_claims?: { global_name?: string; }; avatar_url?: string; picture?: string; provider_id?: string; sub?: string; email_verified?: boolean; phone_verified?: boolean; iss?: string; } async function ensureUniqueUsername( supabase: any, baseUsername: string, userId: string, maxAttempts: number = 10 ): Promise { let username = baseUsername.toLowerCase(); let attempt = 0; while (attempt < maxAttempts) { const { data: existing } = await supabase .from('profiles') .select('user_id') .eq('username', username) .neq('user_id', userId) .maybeSingle(); if (!existing) { return username; } attempt++; username = `${baseUsername.toLowerCase()}_${attempt}`; } // Fallback to UUID-based username return `user_${userId.substring(0, 8)}`; } 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 = req.headers.get('Authorization')!.replace('Bearer ', ''); const { data: { user }, error: authError } = await supabase.auth.getUser(token); if (authError || !user) { throw new Error('Unauthorized'); } addSpanEvent(context.span, 'user_authenticated', { userId: user.id }); // CRITICAL: Check ban status immediately const { data: banProfile } = await supabase .from('profiles') .select('banned, ban_reason') .eq('user_id', user.id) .single(); if (banProfile?.banned) { const message = banProfile.ban_reason ? `Your account has been suspended. Reason: ${banProfile.ban_reason}` : 'Your account has been suspended. Contact support for assistance.'; addSpanEvent(context.span, 'user_banned', { hasBanReason: !!banProfile.ban_reason }); return new Response(JSON.stringify({ error: 'Account suspended', message, ban_reason: banProfile.ban_reason }), { status: 403, 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; if (provider === 'discord' && user.identities && user.identities.length > 0) { const discordIdentity = user.identities.find(i => i.provider === 'discord'); if (discordIdentity) { userMetadata = discordIdentity.identity_data || {}; } } let avatarUrl: string | null = null; let displayName: string | null = null; let usernameBase: string | null = null; // Extract provider-specific data if (provider === 'google') { const googleData = userMetadata as GoogleUserMetadata; avatarUrl = googleData.picture || null; displayName = googleData.name || null; usernameBase = googleData.email?.split('@')[0] || null; } else if (provider === 'discord') { const discordData = userMetadata as DiscordUserMetadata; const discordId = discordData.provider_id || discordData.sub || null; displayName = discordData.custom_claims?.global_name || discordData.full_name || discordData.name || null; usernameBase = discordData.full_name || discordData.name?.split('#')[0] || null; avatarUrl = discordData.avatar_url || discordData.picture || null; if (!usernameBase) { usernameBase = discordId; } } else { return new Response(JSON.stringify({ success: true, message: 'Provider not supported' }), { 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') .select('avatar_image_id, username') .eq('user_id', user.id) .single(); if (profile?.avatar_image_id) { addSpanEvent(context.span, 'avatar_exists_skip'); return new Response(JSON.stringify({ success: true, message: 'Avatar already exists' }), { headers: { 'Content-Type': 'application/json' }, }); } let cloudflareImageId: string | null = null; let cloudflareImageUrl: string | null = null; // Download and upload avatar to Cloudflare 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); const imageResponse = await fetch(avatarUrl, { signal: controller.signal, }); clearTimeout(timeout); if (!imageResponse.ok) { throw new Error(`Failed to download avatar: ${imageResponse.statusText}`); } const imageBlob = await imageResponse.blob(); if (imageBlob.size > 10 * 1024 * 1024) { throw new Error('Image too large (max 10MB)'); } addSpanEvent(context.span, 'avatar_downloaded', { size: imageBlob.size }); // Get upload URL from Cloudflare const uploadUrlResponse = await fetch( `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v2/direct_upload`, { method: 'POST', headers: { 'Authorization': `Bearer ${CLOUDFLARE_API_TOKEN}`, }, } ); if (!uploadUrlResponse.ok) { throw new Error('Failed to get Cloudflare upload URL'); } const uploadData = await uploadUrlResponse.json(); const uploadURL = uploadData.result.uploadURL; // Upload to Cloudflare const formData = new FormData(); formData.append('file', imageBlob, 'avatar.png'); const uploadResponse = await fetch(uploadURL, { method: 'POST', body: formData, }); if (!uploadResponse.ok) { throw new Error('Failed to upload to Cloudflare'); } const result = await uploadResponse.json(); if (result.success) { cloudflareImageId = result.result.id; cloudflareImageUrl = `https://cdn.thrillwiki.com/images/${cloudflareImageId}/avatar`; addSpanEvent(context.span, 'avatar_uploaded', { imageId: cloudflareImageId }); } else { throw new Error('Cloudflare upload failed'); } } catch (error) { addSpanEvent(context.span, 'avatar_upload_failed', { error: error.message }); // Continue without avatar - don't block profile creation } } // Update profile with enhanced data const updateData: any = {}; if (cloudflareImageId) { updateData.avatar_image_id = cloudflareImageId; updateData.avatar_url = cloudflareImageUrl; } if (displayName) { updateData.display_name = displayName; } // Update username if it's currently a generic UUID-based username if (usernameBase && profile?.username?.startsWith('user_')) { const newUsername = await ensureUniqueUsername(supabase, usernameBase, user.id); updateData.username = newUsername; addSpanEvent(context.span, 'username_updated', { oldUsername: profile.username, newUsername }); } // Only update if we have data to update if (Object.keys(updateData).length > 0) { const { error: updateError } = await supabase .from('profiles') .update(updateData) .eq('user_id', user.id); if (updateError) { throw new Error('Failed to update profile'); } addSpanEvent(context.span, 'profile_updated', { fieldsUpdated: Object.keys(updateData).length }); } return new Response(JSON.stringify({ success: true, avatar_uploaded: !!cloudflareImageId, profile_updated: Object.keys(updateData).length > 0 }), { headers: { 'Content-Type': 'application/json' }, }); } );