mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:51:14 -05:00
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.
This commit is contained in:
@@ -1,23 +1,12 @@
|
||||
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';
|
||||
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');
|
||||
|
||||
// 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;
|
||||
@@ -27,15 +16,15 @@ interface GoogleUserMetadata {
|
||||
|
||||
interface DiscordUserMetadata {
|
||||
email?: string;
|
||||
name?: string; // "username#0" format
|
||||
full_name?: string; // "username" without discriminator
|
||||
name?: string;
|
||||
full_name?: string;
|
||||
custom_claims?: {
|
||||
global_name?: string; // Display name like "PacNPal"
|
||||
global_name?: string;
|
||||
};
|
||||
avatar_url?: string; // Full CDN URL
|
||||
picture?: string; // Alternative full CDN URL
|
||||
provider_id?: string; // Discord user ID
|
||||
sub?: string; // Alternative Discord user ID
|
||||
avatar_url?: string;
|
||||
picture?: string;
|
||||
provider_id?: string;
|
||||
sub?: string;
|
||||
email_verified?: boolean;
|
||||
phone_verified?: boolean;
|
||||
iss?: string;
|
||||
@@ -70,39 +59,29 @@ async function ensureUniqueUsername(
|
||||
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' },
|
||||
});
|
||||
}
|
||||
|
||||
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 = authHeader.replace('Bearer ', '');
|
||||
const token = req.headers.get('Authorization')!.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' },
|
||||
});
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
edgeLogger.info('Processing profile for user', { action: 'oauth_profile', userId: user.id, requestId: tracking.requestId });
|
||||
addSpanEvent(context.span, 'user_authenticated', { userId: user.id });
|
||||
|
||||
// CRITICAL: Check ban status immediately
|
||||
const { data: banProfile } = await supabase
|
||||
@@ -112,30 +91,24 @@ Deno.serve(async (req) => {
|
||||
.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
|
||||
});
|
||||
addSpanEvent(context.span, 'user_banned', { hasBanReason: !!banProfile.ban_reason });
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Account suspended',
|
||||
message,
|
||||
ban_reason: banProfile.ban_reason,
|
||||
requestId: tracking.requestId
|
||||
ban_reason: banProfile.ban_reason
|
||||
}), {
|
||||
status: 403,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId },
|
||||
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;
|
||||
@@ -143,18 +116,6 @@ Deno.serve(async (req) => {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,53 +129,28 @@ Deno.serve(async (req) => {
|
||||
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' },
|
||||
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')
|
||||
@@ -223,10 +159,9 @@ Deno.serve(async (req) => {
|
||||
.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 },
|
||||
addSpanEvent(context.span, 'avatar_exists_skip');
|
||||
return new Response(JSON.stringify({ success: true, message: 'Avatar already exists' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -234,26 +169,13 @@ Deno.serve(async (req) => {
|
||||
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 });
|
||||
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); // 10 second timeout
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const imageResponse = await fetch(avatarUrl, {
|
||||
signal: controller.signal,
|
||||
@@ -266,17 +188,11 @@ Deno.serve(async (req) => {
|
||||
|
||||
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,
|
||||
});
|
||||
addSpanEvent(context.span, 'avatar_downloaded', { size: imageBlob.size });
|
||||
|
||||
// Get upload URL from Cloudflare
|
||||
const uploadUrlResponse = await fetch(
|
||||
@@ -296,8 +212,6 @@ Deno.serve(async (req) => {
|
||||
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');
|
||||
@@ -316,22 +230,13 @@ Deno.serve(async (req) => {
|
||||
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 });
|
||||
addSpanEvent(context.span, 'avatar_uploaded', { imageId: cloudflareImageId });
|
||||
} 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
|
||||
}
|
||||
} catch (error) {
|
||||
addSpanEvent(context.span, 'avatar_upload_failed', { error: error.message });
|
||||
// Continue without avatar - don't block profile creation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +256,7 @@ Deno.serve(async (req) => {
|
||||
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 });
|
||||
addSpanEvent(context.span, 'username_updated', { oldUsername: profile.username, newUsername });
|
||||
}
|
||||
|
||||
// Only update if we have data to update
|
||||
@@ -362,34 +267,18 @@ Deno.serve(async (req) => {
|
||||
.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' },
|
||||
});
|
||||
throw new Error('Failed to update profile');
|
||||
}
|
||||
|
||||
edgeLogger.info('Profile updated successfully', { action: 'oauth_profile', requestId: tracking.requestId });
|
||||
addSpanEvent(context.span, 'profile_updated', { fieldsUpdated: Object.keys(updateData).length });
|
||||
}
|
||||
|
||||
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
|
||||
profile_updated: Object.keys(updateData).length > 0
|
||||
}), {
|
||||
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 },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user