import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }; const CLOUDFLARE_ACCOUNT_ID = Deno.env.get('CLOUDFLARE_ACCOUNT_ID'); const CLOUDFLARE_ACCOUNT_HASH = Deno.env.get('CLOUDFLARE_ACCOUNT_HASH'); const CLOUDFLARE_API_TOKEN = Deno.env.get('CLOUDFLARE_IMAGES_API_TOKEN'); // Validate configuration at startup if (!CLOUDFLARE_ACCOUNT_ID || !CLOUDFLARE_ACCOUNT_HASH || !CLOUDFLARE_API_TOKEN) { console.error('[OAuth Profile] Missing Cloudflare configuration:', { hasAccountId: !!CLOUDFLARE_ACCOUNT_ID, hasAccountHash: !!CLOUDFLARE_ACCOUNT_HASH, hasApiToken: !!CLOUDFLARE_API_TOKEN, }); console.error('[OAuth Profile] Please configure CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_ACCOUNT_HASH, and CLOUDFLARE_IMAGES_API_TOKEN in Supabase Edge Function secrets'); } interface GoogleUserMetadata { email?: string; name?: string; picture?: string; email_verified?: boolean; } interface DiscordUserMetadata { email?: string; username?: string; global_name?: string; discriminator?: string; avatar?: string; id?: string; verified?: boolean; flags?: number; premium_type?: number; } Deno.serve(async (req) => { 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' }, }); } const supabaseUrl = Deno.env.get('SUPABASE_URL')!; const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; const supabase = createClient(supabaseUrl, supabaseKey); // Verify JWT and get user const token = authHeader.replace('Bearer ', ''); const { data: { user }, error: authError } = await supabase.auth.getUser(token); if (authError || !user) { console.error('[OAuth Profile] Authentication failed:', authError); return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } console.log('[OAuth Profile] Processing profile for user:', user.id); const provider = user.app_metadata?.provider; // 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 || {}; // Debug: Log full identity_data to see what Discord actually returns console.log('[OAuth Profile] Discord identity_data (full):', JSON.stringify(discordIdentity.identity_data)); console.log('[OAuth Profile] Using Discord identity data:', { hasAvatar: !!userMetadata.avatar, hasUsername: !!userMetadata.username, hasGlobalName: !!userMetadata.global_name, hasId: !!userMetadata.id, hasEmail: !!userMetadata.email, avatarValue: userMetadata.avatar, idValue: userMetadata.id }); } else { console.warn('[OAuth Profile] Discord provider found but no Discord identity in user.identities'); } } 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; console.log('[OAuth Profile] Google user:', { avatarUrl, displayName, usernameBase }); } else if (provider === 'discord') { const discordData = userMetadata as DiscordUserMetadata; // Prefer global_name, fall back to username displayName = discordData.global_name || discordData.username || null; usernameBase = discordData.username || null; // Extract email (Discord provides it with email scope) const discordEmail = discordData.email || null; // Validate we have minimum required data if (!discordData.id) { console.error('[OAuth Profile] Discord user ID missing - OAuth scopes may not be configured correctly'); } if (!discordData.username) { console.warn('[OAuth Profile] Discord username missing - using ID as fallback'); usernameBase = discordData.id || null; } // Construct Discord avatar URL with proper format detection if (discordData.avatar && discordData.id) { // Discord animated avatars have 'a_' prefix and use .gif extension const isAnimated = discordData.avatar.startsWith('a_'); const extension = isAnimated ? 'gif' : 'png'; avatarUrl = `https://cdn.discordapp.com/avatars/${discordData.id}/${discordData.avatar}.${extension}?size=512`; } else if (!discordData.avatar && discordData.id) { // User has no custom avatar - use Discord default avatar const defaultAvatarIndex = (parseInt(discordData.id) >> 22) % 6; avatarUrl = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarIndex}.png`; console.log('[OAuth Profile] Using Discord default avatar for user without custom avatar'); } console.log('[OAuth Profile] Discord user:', { avatarUrl, displayName, usernameBase, email: discordEmail, hasAnimatedAvatar: discordData.avatar?.startsWith('a_'), hasCustomAvatar: !!discordData.avatar }); } else { console.log('[OAuth Profile] Unsupported provider:', provider); return new Response(JSON.stringify({ success: true, message: 'Provider not supported' }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } // 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) { console.log('[OAuth Profile] Avatar already exists, skipping'); return new Response(JSON.stringify({ success: true, message: 'Avatar already exists' }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } let cloudflareImageId: string | null = null; let cloudflareImageUrl: string | null = null; // Download and upload avatar to Cloudflare if (avatarUrl) { // Validate secrets before attempting upload if (!CLOUDFLARE_ACCOUNT_ID || !CLOUDFLARE_ACCOUNT_HASH || !CLOUDFLARE_API_TOKEN) { console.warn('[OAuth Profile] Cloudflare secrets not configured, skipping avatar upload'); console.warn('[OAuth Profile] Missing:', { accountId: !CLOUDFLARE_ACCOUNT_ID, accountHash: !CLOUDFLARE_ACCOUNT_HASH, apiToken: !CLOUDFLARE_API_TOKEN, }); } else { try { console.log('[OAuth Profile] Downloading avatar from:', avatarUrl); // Download image with timeout const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout 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(); // Validate image size (max 10MB) if (imageBlob.size > 10 * 1024 * 1024) { throw new Error('Image too large (max 10MB)'); } console.log('[OAuth Profile] Downloaded image:', { size: imageBlob.size, type: imageBlob.type, }); // 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; console.log('[OAuth Profile] Got Cloudflare upload URL'); // 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://imagedelivery.net/${CLOUDFLARE_ACCOUNT_HASH}/${cloudflareImageId}/avatar`; console.log('[OAuth Profile] Uploaded to Cloudflare:', { cloudflareImageId, cloudflareImageUrl }); } else { throw new Error('Cloudflare upload failed'); } } catch (error) { console.error('[OAuth Profile] Avatar upload failed:', { error: error.message, provider: provider, accountId: CLOUDFLARE_ACCOUNT_ID, accountHash: CLOUDFLARE_ACCOUNT_HASH, hasToken: !!CLOUDFLARE_API_TOKEN, avatarUrl, }); // 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; } // 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) { console.error('[OAuth Profile] Failed to update profile:', updateError); return new Response(JSON.stringify({ error: 'Failed to update profile' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } console.log('[OAuth Profile] Profile updated successfully'); } return new Response(JSON.stringify({ success: true, avatar_uploaded: !!cloudflareImageId, profile_updated: Object.keys(updateData).length > 0, }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } catch (error) { console.error('[OAuth Profile] Error:', error); return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } });