Files
thrilltrack-explorer/supabase/functions/process-oauth-profile/index.ts
gpt-engineer-app[bot] e28dc97d71 Migrate Phase 1 Functions
Migrate 8 high-priority functions (admin-delete-user, mfa-unenroll, confirm-account-deletion, request-account-deletion, send-contact-message, upload-image, validate-email-backend, process-oauth-profile) to wrapEdgeFunction pattern. Replace manual CORS/auth, add shared validations, integrate standardized error handling, and preserve existing rate limiting where applicable. Update implementations to leverage context span, requestId, and improved logging for consistent error reporting and tracing.
2025-11-11 03:03:26 +00:00

285 lines
9.0 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 { 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<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)}`;
}
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' },
});
}
);