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:
gpt-engineer-app[bot]
2025-11-11 03:03:26 +00:00
parent 7181fdbcac
commit e28dc97d71
8 changed files with 394 additions and 1471 deletions

View File

@@ -1,9 +1,9 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { getAllowedOrigin, getCorsHeaders } from '../_shared/cors.ts'
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts'
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts'
import { formatEdgeError } from '../_shared/errorFormatter.ts'
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts'
import { validateString } from '../_shared/typeValidation.ts'
import { addSpanEvent } from '../_shared/logger.ts'
// Helper to create authenticated Supabase client
const createAuthenticatedSupabaseClient = (authHeader: string) => {
@@ -42,45 +42,39 @@ async function reportBanEvasionToAlerts(
});
} catch (error) {
// Non-blocking - log but don't fail the response
edgeLogger.warn('Failed to report ban evasion', {
error: formatEdgeError(error),
requestId
});
}
}
// Apply strict rate limiting (5 requests/minute) to prevent abuse
const uploadRateLimiter = rateLimiters.strict;
const handler = createEdgeFunction(
{
name: 'upload-image',
requireAuth: false, // Auth checked per-method
corsHeaders: {} // Dynamic CORS
},
async (req, context) => {
const requestOrigin = req.headers.get('origin');
const allowedOrigin = getAllowedOrigin(requestOrigin);
serve(withRateLimit(async (req) => {
const tracking = startRequest();
const requestOrigin = req.headers.get('origin');
const allowedOrigin = getAllowedOrigin(requestOrigin);
// Check if this is a CORS request with a disallowed origin
if (requestOrigin && !allowedOrigin) {
addSpanEvent(context.span, 'cors_rejected', { origin: requestOrigin });
return new Response(
JSON.stringify({
error: 'Origin not allowed',
message: 'The origin of this request is not allowed to access this resource'
}),
{
status: 403,
headers: { 'Content-Type': 'application/json' }
}
);
}
// Check if this is a CORS request with a disallowed origin
if (requestOrigin && !allowedOrigin) {
edgeLogger.warn('CORS request rejected', { action: 'cors_validation', origin: requestOrigin, requestId: tracking.requestId });
return new Response(
JSON.stringify({
error: 'Origin not allowed',
message: 'The origin of this request is not allowed to access this resource'
}),
{
status: 403,
headers: { 'Content-Type': 'application/json' }
}
);
}
const corsHeaders = getCorsHeaders(allowedOrigin);
context.span.setAttribute('http_method', req.method);
context.span.setAttribute('action', 'upload_image');
// Define CORS headers at function scope so they're available in catch block
const corsHeaders = getCorsHeaders(allowedOrigin);
// Handle CORS preflight requests
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders })
}
try {
const CLOUDFLARE_ACCOUNT_ID = Deno.env.get('CLOUDFLARE_ACCOUNT_ID')
const CLOUDFLARE_IMAGES_API_TOKEN = Deno.env.get('CLOUDFLARE_IMAGES_API_TOKEN')
@@ -104,200 +98,72 @@ serve(withRateLimit(async (req) => {
)
}
// Verify JWT token
const supabase = createAuthenticatedSupabaseClient(authHeader)
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
edgeLogger.error('Auth verification failed', { action: 'delete_auth', error: authError?.message })
return new Response(
JSON.stringify({
error: 'Invalid authentication',
message: 'Authentication token is invalid or expired'
}),
{
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
throw new Error('Invalid authentication');
}
context.span.setAttribute('user_id', user.id);
// Check if user is banned
const { data: profile, error: profileError } = await supabase
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', user.id)
.single()
if (profileError || !profile) {
edgeLogger.error('Failed to fetch user profile', { action: 'delete_profile_check', userId: user.id })
return new Response(
JSON.stringify({
error: 'User profile not found',
message: 'Unable to verify user profile'
}),
{
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
if (profile.banned) {
// Report ban evasion attempt (non-blocking)
await reportBanEvasionToAlerts(supabase, user.id, 'image_delete', tracking.requestId);
const duration = endRequest(tracking);
edgeLogger.warn('Banned user blocked from image deletion', {
userId: user.id,
requestId: tracking.requestId
});
if (profile?.banned) {
await reportBanEvasionToAlerts(supabase, user.id, 'image_delete', context.requestId);
addSpanEvent(context.span, 'banned_user_blocked', { action: 'delete' });
return new Response(
JSON.stringify({
error: 'Account suspended',
message: 'Account suspended. Contact support for assistance.',
requestId: tracking.requestId
message: 'Account suspended. Contact support for assistance.'
}),
{
status: 403,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
}
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
// Delete image from Cloudflare
edgeLogger.info('Deleting image', { action: 'delete_image', requestId: tracking.requestId });
let requestBody;
try {
requestBody = await req.json();
} catch (error: unknown) {
const errorMessage = formatEdgeError(error);
edgeLogger.error('Invalid JSON in delete request', {
error: errorMessage,
requestId: tracking.requestId
});
return new Response(
JSON.stringify({
error: 'Invalid JSON',
message: 'Request body must be valid JSON'
}),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
const requestBody = await req.json();
const { imageId } = requestBody;
if (!imageId || typeof imageId !== 'string' || imageId.trim() === '') {
return new Response(
JSON.stringify({
error: 'Invalid imageId',
message: 'imageId is required and must be a non-empty string'
}),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
validateString(imageId, 'imageId', { userId: user.id, requestId: context.requestId });
// Validate imageId format - Cloudflare accepts UUIDs and alphanumeric IDs
// Allow: alphanumeric, hyphens, underscores (common ID formats)
// Reject: special characters that could cause injection or path traversal
// Validate imageId format
const validImageIdPattern = /^[a-zA-Z0-9_-]{1,100}$/;
if (!validImageIdPattern.test(imageId)) {
return new Response(
JSON.stringify({
error: 'Invalid imageId format',
message: 'imageId must be alphanumeric with optional hyphens/underscores (max 100 chars)'
}),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
throw new Error('Invalid imageId format - must be alphanumeric with optional hyphens/underscores (max 100 chars)');
}
let deleteResponse;
try {
deleteResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v1/${imageId}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${CLOUDFLARE_IMAGES_API_TOKEN}`,
},
}
)
} catch (fetchError) {
edgeLogger.error('Network error deleting image', {
error: String(fetchError),
requestId: tracking.requestId
});
return new Response(
JSON.stringify({
error: 'Network error',
message: 'Unable to reach Cloudflare Images API'
}),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
addSpanEvent(context.span, 'delete_image_start', { imageId });
let deleteResult;
try {
deleteResult = await deleteResponse.json()
} catch (parseError) {
edgeLogger.error('Failed to parse Cloudflare delete response', {
error: String(parseError),
requestId: tracking.requestId
});
return new Response(
JSON.stringify({
error: 'Invalid response',
message: 'Unable to parse response from Cloudflare Images API'
}),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
const deleteResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v1/${imageId}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${CLOUDFLARE_IMAGES_API_TOKEN}`,
},
}
)
const deleteResult = await deleteResponse.json()
if (!deleteResponse.ok) {
edgeLogger.error('Cloudflare delete error', {
result: deleteResult,
requestId: tracking.requestId
});
return new Response(
JSON.stringify({
error: 'Failed to delete image',
message: deleteResult.errors?.[0]?.message || deleteResult.error || 'Unknown error occurred',
details: deleteResult.errors || deleteResult.error
}),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
throw new Error(deleteResult.errors?.[0]?.message || deleteResult.error || 'Failed to delete image');
}
const duration = endRequest(tracking);
edgeLogger.info('Image deleted successfully', { action: 'delete_image', requestId: tracking.requestId, duration });
addSpanEvent(context.span, 'image_deleted', { imageId });
return new Response(
JSON.stringify({ success: true, deleted: true, requestId: tracking.requestId }),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
}
JSON.stringify({ success: true, deleted: true }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
@@ -317,43 +183,30 @@ serve(withRateLimit(async (req) => {
)
}
// Verify JWT token
const supabase = createAuthenticatedSupabaseClient(authHeader)
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
edgeLogger.error('Auth verification failed for POST', {
error: authError?.message,
requestId: tracking.requestId
});
return new Response(
JSON.stringify({
error: 'Invalid authentication',
message: 'Authentication token is invalid or expired'
}),
{
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
throw new Error('Invalid authentication');
}
context.span.setAttribute('user_id', user.id);
// Check if user is banned
const { data: profile, error: profileError } = await supabase
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', user.id)
.single()
if (profileError || !profile) {
edgeLogger.error('Failed to fetch user profile for POST', {
error: profileError?.message,
requestId: tracking.requestId
});
if (profile?.banned) {
await reportBanEvasionToAlerts(supabase, user.id, 'image_upload', context.requestId);
addSpanEvent(context.span, 'banned_user_blocked', { action: 'upload' });
return new Response(
JSON.stringify({
error: 'User profile not found',
message: 'Unable to verify user profile'
error: 'Account suspended',
message: 'Account suspended. Contact support for assistance.'
}),
{
status: 403,
@@ -362,31 +215,7 @@ serve(withRateLimit(async (req) => {
)
}
if (profile.banned) {
// Report ban evasion attempt (non-blocking)
await reportBanEvasionToAlerts(supabase, user.id, 'image_upload', tracking.requestId);
const duration = endRequest(tracking);
edgeLogger.warn('Banned user blocked from image upload', {
userId: user.id,
requestId: tracking.requestId
});
return new Response(
JSON.stringify({
error: 'Account suspended',
message: 'Account suspended. Contact support for assistance.',
requestId: tracking.requestId
}),
{
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
}
)
}
// Request a direct upload URL from Cloudflare
edgeLogger.info('Requesting upload URL', { action: 'request_upload_url', requestId: tracking.requestId });
let requestBody;
try {
requestBody = await req.json();
@@ -394,111 +223,44 @@ serve(withRateLimit(async (req) => {
requestBody = {};
}
// Validate request body structure
if (requestBody && typeof requestBody !== 'object') {
return new Response(
JSON.stringify({
error: 'Invalid request body',
message: 'Request body must be a valid JSON object'
}),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
const { metadata = {}, variant = 'public', requireSignedURLs = false } = requestBody;
// Create FormData for the request (Cloudflare API requires multipart/form-data)
addSpanEvent(context.span, 'upload_url_request_start');
// Create FormData for the request
const formData = new FormData()
formData.append('requireSignedURLs', requireSignedURLs.toString())
// Add metadata to the request if provided
if (metadata && Object.keys(metadata).length > 0) {
formData.append('metadata', JSON.stringify(metadata))
}
let directUploadResponse;
try {
directUploadResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v2/direct_upload`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${CLOUDFLARE_IMAGES_API_TOKEN}`,
},
body: formData,
}
)
} catch (fetchError) {
edgeLogger.error('Network error getting upload URL', {
error: String(fetchError),
requestId: tracking.requestId
});
return new Response(
JSON.stringify({
error: 'Network error',
message: 'Unable to reach Cloudflare Images API'
}),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
const directUploadResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v2/direct_upload`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${CLOUDFLARE_IMAGES_API_TOKEN}`,
},
body: formData,
}
)
let directUploadResult;
try {
directUploadResult = await directUploadResponse.json()
} catch (parseError) {
edgeLogger.error('Failed to parse Cloudflare upload response', {
error: String(parseError),
requestId: tracking.requestId
});
return new Response(
JSON.stringify({
error: 'Invalid response',
message: 'Unable to parse response from Cloudflare Images API'
}),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
const directUploadResult = await directUploadResponse.json()
if (!directUploadResponse.ok) {
edgeLogger.error('Cloudflare direct upload error', {
result: directUploadResult,
requestId: tracking.requestId
});
return new Response(
JSON.stringify({
error: 'Failed to get upload URL',
message: directUploadResult.errors?.[0]?.message || directUploadResult.error || 'Unable to create upload URL',
details: directUploadResult.errors || directUploadResult.error
}),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
throw new Error(directUploadResult.errors?.[0]?.message || directUploadResult.error || 'Unable to create upload URL');
}
// Return the upload URL and image ID to the client
const duration = endRequest(tracking);
edgeLogger.info('Upload URL created', { action: 'upload_url_success', requestId: tracking.requestId, duration });
addSpanEvent(context.span, 'upload_url_created', { imageId: directUploadResult.result.id });
return new Response(
JSON.stringify({
success: true,
uploadURL: directUploadResult.result.uploadURL,
id: directUploadResult.result.id,
requestId: tracking.requestId
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
}
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
@@ -518,117 +280,43 @@ serve(withRateLimit(async (req) => {
)
}
// Verify JWT token
const supabase = createAuthenticatedSupabaseClient(authHeader)
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
edgeLogger.error('Auth verification failed for GET', {
error: authError?.message,
requestId: tracking.requestId
});
return new Response(
JSON.stringify({
error: 'Invalid authentication',
message: 'Authentication token is invalid or expired'
}),
{
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
throw new Error('Invalid authentication');
}
context.span.setAttribute('user_id', user.id);
// Check image status endpoint
const url = new URL(req.url)
const imageId = url.searchParams.get('id')
if (!imageId || imageId.trim() === '') {
return new Response(
JSON.stringify({
error: 'Missing id parameter',
message: 'id query parameter is required and must be non-empty'
}),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
validateString(imageId, 'id', { userId: user.id, requestId: context.requestId });
let imageResponse;
try {
imageResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v1/${imageId}`,
{
headers: {
'Authorization': `Bearer ${CLOUDFLARE_IMAGES_API_TOKEN}`,
},
}
)
} catch (fetchError) {
edgeLogger.error('Network error fetching image status', {
error: String(fetchError),
requestId: tracking.requestId
});
return new Response(
JSON.stringify({
error: 'Network error',
message: 'Unable to reach Cloudflare Images API'
}),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
addSpanEvent(context.span, 'get_image_status_start', { imageId });
let imageResult;
try {
imageResult = await imageResponse.json()
} catch (parseError) {
edgeLogger.error('Failed to parse Cloudflare image status response', {
error: String(parseError),
requestId: tracking.requestId
});
return new Response(
JSON.stringify({
error: 'Invalid response',
message: 'Unable to parse response from Cloudflare Images API'
}),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
const imageResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v1/${imageId}`,
{
headers: {
'Authorization': `Bearer ${CLOUDFLARE_IMAGES_API_TOKEN}`,
},
}
)
const imageResult = await imageResponse.json()
if (!imageResponse.ok) {
edgeLogger.error('Cloudflare image status error', {
result: imageResult,
requestId: tracking.requestId
});
return new Response(
JSON.stringify({
error: 'Failed to get image status',
message: imageResult.errors?.[0]?.message || imageResult.error || 'Unable to retrieve image information',
details: imageResult.errors || imageResult.error
}),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
throw new Error(imageResult.errors?.[0]?.message || imageResult.error || 'Unable to retrieve image information');
}
// Return the image details with convenient URLs
const result = imageResult.result
const duration = endRequest(tracking);
// Construct CDN URLs for display
const baseUrl = `https://cdn.thrillwiki.com/images/${result.id}`
edgeLogger.info('Image status retrieved', { action: 'get_image_status', requestId: tracking.requestId, duration });
addSpanEvent(context.span, 'image_status_retrieved', { imageId: result.id });
return new Response(
JSON.stringify({
@@ -637,49 +325,29 @@ serve(withRateLimit(async (req) => {
uploaded: result.uploaded,
variants: result.variants,
draft: result.draft,
// Provide convenient URLs using proper Cloudflare Images format
urls: result.uploaded ? {
public: `${baseUrl}/public`,
thumbnail: `${baseUrl}/thumbnail`,
medium: `${baseUrl}/medium`,
large: `${baseUrl}/large`,
avatar: `${baseUrl}/avatar`,
} : null,
requestId: tracking.requestId
} : null
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
}
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const duration = endRequest(tracking);
return new Response(
JSON.stringify({
error: 'Method not allowed',
message: 'HTTP method not supported for this endpoint',
requestId: tracking.requestId
message: 'HTTP method not supported for this endpoint'
}),
{
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
}
)
} catch (error: unknown) {
const duration = endRequest(tracking);
const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred';
edgeLogger.error('Upload function error', { action: 'upload_error', requestId: tracking.requestId, duration, error: errorMessage });
return new Response(
JSON.stringify({
error: 'Internal server error',
message: errorMessage,
requestId: tracking.requestId
}),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
}, uploadRateLimiter, getCorsHeaders(allowedOrigin)));
);
export default withRateLimit(handler, rateLimiters.strict, {} as any);