Files
thrilltrack-explorer/supabase/functions/process-oauth-profile/index.ts
gpt-engineer-app[bot] bf3da6414a Centralize CORS configuration
Consolidate CORS handling by introducing a shared supabase/functions/_shared/cors.ts and migrate edge functions to import from it. Remove inline cors.ts usage across functions, standardize headers (including traceparent and x-request-id), and prepare for environment-aware origins.
2025-11-10 21:28:46 +00:00

396 lines
15 KiB
TypeScript

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';
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;
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<string> {
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) {
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' },
});
}
edgeLogger.info('Processing profile for user', { action: 'oauth_profile', userId: user.id, requestId: tracking.requestId });
// 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 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
});
return new Response(JSON.stringify({
error: 'Account suspended',
message,
ban_reason: banProfile.ban_reason,
requestId: tracking.requestId
}), {
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId },
});
}
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 || {};
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 });
}
}
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;
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' },
});
}
// 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);
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 },
});
}
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) {
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
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)');
}
edgeLogger.info('Downloaded image', {
action: 'oauth_profile_upload',
requestId: tracking.requestId,
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;
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');
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`;
edgeLogger.info('Uploaded to Cloudflare', { action: 'oauth_profile_upload', requestId: tracking.requestId, cloudflareImageId, cloudflareImageUrl });
} 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
}
}
}
// 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;
edgeLogger.info('Updating generic username', { action: 'oauth_profile', requestId: tracking.requestId, 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) {
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' },
});
}
edgeLogger.info('Profile updated successfully', { action: 'oauth_profile', requestId: tracking.requestId });
}
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
}), {
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 },
});
}
});