mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 13:31:12 -05:00
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.
285 lines
9.0 KiB
TypeScript
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' },
|
|
});
|
|
}
|
|
);
|