import { serve } from "https://deno.land/std@0.168.0/http/server.ts" import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts' import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts' // Environment-aware CORS configuration const getAllowedOrigin = (requestOrigin: string | null): string | null => { // If no origin header, it's not a CORS request (same-origin or server-to-server) if (!requestOrigin) { return null; } const environment = Deno.env.get('ENVIRONMENT') || 'development'; // Production allowlist - configure via ALLOWED_ORIGINS environment variable // Format: comma-separated list of origins, e.g., "https://example.com,https://www.example.com" const allowedOriginsEnv = Deno.env.get('ALLOWED_ORIGINS') || ''; const allowedOrigins = allowedOriginsEnv.split(',').filter(origin => origin.trim()); // In development, only allow localhost and Replit domains - nothing else if (environment === 'development') { if ( requestOrigin.includes('localhost') || requestOrigin.includes('127.0.0.1') || requestOrigin.includes('.repl.co') || requestOrigin.includes('.replit.dev') ) { return requestOrigin; } // Origin not allowed in development - log and deny edgeLogger.warn('CORS origin not allowed in development mode', { origin: requestOrigin }); return null; } // In production, only allow specific domains from environment variable if (allowedOrigins.includes(requestOrigin)) { return requestOrigin; } // Origin not allowed in production - log and deny edgeLogger.warn('CORS origin not allowed in production mode', { origin: requestOrigin }); return null; }; const getCorsHeaders = (allowedOrigin: string | null): Record => { // If no allowed origin, return empty headers (no CORS access) if (!allowedOrigin) { return {}; } return { 'Access-Control-Allow-Origin': allowedOrigin, 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', 'Access-Control-Allow-Credentials': 'true', }; }; // Helper to create authenticated Supabase client const createAuthenticatedSupabaseClient = (authHeader: string) => { const supabaseUrl = Deno.env.get('SUPABASE_URL') const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY') if (!supabaseUrl || !supabaseAnonKey) { throw new Error('Missing Supabase environment variables') } return createClient(supabaseUrl, supabaseAnonKey, { global: { headers: { Authorization: authHeader } } }) } /** * Report ban evasion attempts to system alerts */ async function reportBanEvasionToAlerts( supabaseClient: any, userId: string, action: string, requestId: string ): Promise { try { await supabaseClient.rpc('create_system_alert', { p_alert_type: 'ban_attempt', p_severity: 'high', p_message: `Banned user attempted image upload: ${action}`, p_metadata: { user_id: userId, action, request_id: requestId, timestamp: new Date().toISOString() } }); } catch (error) { // Non-blocking - log but don't fail the response edgeLogger.warn('Failed to report ban evasion', { error: error instanceof Error ? error.message : String(error), requestId }); } } // Apply strict rate limiting (5 requests/minute) to prevent abuse const uploadRateLimiter = rateLimiters.strict; 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) { 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' } } ); } // 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') if (!CLOUDFLARE_ACCOUNT_ID || !CLOUDFLARE_IMAGES_API_TOKEN) { throw new Error('Missing Cloudflare credentials') } if (req.method === 'DELETE') { // Require authentication for DELETE operations const authHeader = req.headers.get('Authorization') if (!authHeader) { return new Response( JSON.stringify({ error: 'Authentication required', message: 'Authentication required for delete operations' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // 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' } } ) } // Check if user is banned const { data: profile, error: profileError } = 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 }); 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 } } ) } // 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 = error instanceof Error ? error.message : String(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 { 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' } } ) } // 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 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' } } ) } 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' } } ) } 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' } } ) } 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' } } ) } const duration = endRequest(tracking); edgeLogger.info('Image deleted successfully', { action: 'delete_image', requestId: tracking.requestId, duration }); return new Response( JSON.stringify({ success: true, deleted: true, requestId: tracking.requestId }), { headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ) } if (req.method === 'POST') { // Require authentication for POST operations const authHeader = req.headers.get('Authorization') if (!authHeader) { return new Response( JSON.stringify({ error: 'Authentication required', message: 'Authentication required for upload operations' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // 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' } } ) } // Check if user is banned const { data: profile, error: profileError } = 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 }); 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_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(); } catch (error: unknown) { 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) 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' } } ) } 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' } } ) } 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' } } ) } // 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 }); 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 } } ) } if (req.method === 'GET') { // Require authentication for GET operations const authHeader = req.headers.get('Authorization') if (!authHeader) { return new Response( JSON.stringify({ error: 'Authentication required', message: 'Authentication required for image status operations' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // 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' } } ) } // 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' } } ) } 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' } } ) } 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' } } ) } 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' } } ) } // 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 }); return new Response( JSON.stringify({ success: true, id: result.id, 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 }), { headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ) } const duration = endRequest(tracking); return new Response( JSON.stringify({ error: 'Method not allowed', message: 'HTTP method not supported for this endpoint', requestId: tracking.requestId }), { 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 } } ) } }, uploadRateLimiter, getCorsHeaders(allowedOrigin)));