mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 15:51:12 -05:00
383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
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<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) {
|
|
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);
|
|
|
|
// 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.';
|
|
|
|
console.log('[OAuth Profile] User is banned, rejecting authentication', {
|
|
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 || {};
|
|
|
|
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 },
|
|
});
|
|
}
|
|
});
|