import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; import { startRequest, endRequest } from '../_shared/logger.ts'; 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_API_TOKEN = Deno.env.get('CLOUDFLARE_IMAGES_API_TOKEN'); // Validate configuration at startup if (!CLOUDFLARE_ACCOUNT_ID || !CLOUDFLARE_API_TOKEN) { console.error('[OAuth Profile] Missing Cloudflare configuration:', { hasAccountId: !!CLOUDFLARE_ACCOUNT_ID, hasApiToken: !!CLOUDFLARE_API_TOKEN, }); console.error('[OAuth Profile] Please configure CLOUDFLARE_ACCOUNT_ID 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; name?: string; // "username#0" format full_name?: string; // "username" without discriminator custom_claims?: { global_name?: string; // Display name like "PacNPal" }; avatar_url?: string; // Full CDN URL picture?: string; // Alternative full CDN URL provider_id?: string; // Discord user ID sub?: string; // Alternative Discord user ID 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)}`; } 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' }, }); } 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 || {}; console.log('[OAuth Profile] Discord identity_data:', { 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 { 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; // 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) { console.error('[OAuth Profile] Discord user ID missing from provider_id/sub - OAuth data incomplete'); } if (!usernameBase) { console.warn('[OAuth Profile] Discord username missing - using ID as fallback'); usernameBase = discordId; } console.log('[OAuth Profile] Discord user (Supabase format):', { avatarUrl, displayName, usernameBase, discordId, email: discordEmail, hasAvatar: !!avatarUrl, source: discordData.avatar_url ? 'avatar_url' : discordData.picture ? 'picture' : 'none' }); } 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) { const duration = endRequest(tracking); console.log('[OAuth Profile] Avatar already exists, skipping', { 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 }, }); } 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_API_TOKEN) { console.warn('[OAuth Profile] Cloudflare secrets not configured, skipping avatar upload'); console.warn('[OAuth Profile] Missing:', { accountId: !CLOUDFLARE_ACCOUNT_ID, 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://cdn.thrillwiki.com/images/${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, 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; } // 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; console.log('[OAuth Profile] Updating generic username from', profile.username, 'to', 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) { 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', { requestId: tracking.requestId }); } const duration = endRequest(tracking); console.log('[OAuth Profile] Processing complete', { requestId: tracking.requestId, duration }); return new Response(JSON.stringify({ success: true, avatar_uploaded: !!cloudflareImageId, profile_updated: Object.keys(updateData).length > 0, requestId: tracking.requestId }), { headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }, }); } catch (error) { const duration = endRequest(tracking); console.error('[OAuth Profile] Error:', error, { requestId: tracking.requestId, duration }); return new Response(JSON.stringify({ error: error.message, requestId: tracking.requestId }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }, }); } });