import { serve } from "https://deno.land/std@0.168.0/http/server.ts" import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' // Environment-aware CORS configuration const getAllowedOrigin = (requestOrigin: string | null): string => { 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, allow localhost and Replit domains if (environment === 'development') { if (requestOrigin) { if ( requestOrigin.includes('localhost') || requestOrigin.includes('127.0.0.1') || requestOrigin.includes('.repl.co') || requestOrigin.includes('.replit.dev') ) { return requestOrigin; } } return '*'; } // In production, only allow specific domains from environment variable if (requestOrigin && allowedOrigins.includes(requestOrigin)) { return requestOrigin; } // Default to first allowed origin for production, or deny if none configured return allowedOrigins.length > 0 ? allowedOrigins[0] : requestOrigin || '*'; }; const getCorsHeaders = (requestOrigin: string | null) => ({ 'Access-Control-Allow-Origin': getAllowedOrigin(requestOrigin), '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 } } }) } serve(async (req) => { const requestOrigin = req.headers.get('origin'); const corsHeaders = getCorsHeaders(requestOrigin); // 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') const CLOUDFLARE_ACCOUNT_HASH = Deno.env.get('CLOUDFLARE_ACCOUNT_HASH') if (!CLOUDFLARE_ACCOUNT_ID || !CLOUDFLARE_IMAGES_API_TOKEN || !CLOUDFLARE_ACCOUNT_HASH) { 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) { console.error('Auth verification failed:', authError) 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) { console.error('Failed to fetch user profile:', profileError) 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) { return new Response( JSON.stringify({ error: 'Account suspended', message: 'Account suspended. Contact support for assistance.' }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // Delete image from Cloudflare let requestBody; try { requestBody = await req.json(); } catch (error) { 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) { console.error('Network error deleting image:', fetchError) 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) { console.error('Failed to parse Cloudflare delete response:', parseError) 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) { console.error('Cloudflare delete error:', deleteResult) 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' } } ) } return new Response( JSON.stringify({ success: true, deleted: true }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } 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) { console.error('Auth verification failed:', authError) 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) { console.error('Failed to fetch user profile:', profileError) 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) { return new Response( JSON.stringify({ error: 'Account suspended', message: 'Account suspended. Contact support for assistance.' }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // Request a direct upload URL from Cloudflare let requestBody; try { requestBody = await req.json(); } catch (error) { 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) { console.error('Network error getting upload URL:', fetchError) 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) { console.error('Failed to parse Cloudflare upload response:', parseError) 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) { console.error('Cloudflare direct upload error:', directUploadResult) 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 return new Response( JSON.stringify({ success: true, uploadURL: directUploadResult.result.uploadURL, id: directUploadResult.result.id, }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } 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) { console.error('Auth verification failed:', authError) 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) { console.error('Network error fetching image status:', fetchError) 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) { console.error('Failed to parse Cloudflare image status response:', parseError) 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) { console.error('Cloudflare image status error:', imageResult) 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 // Construct proper imagedelivery.net URLs using account hash and image ID const baseUrl = `https://imagedelivery.net/${CLOUDFLARE_ACCOUNT_HASH}/${result.id}` 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 }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } return new Response( JSON.stringify({ error: 'Method not allowed', message: 'HTTP method not supported for this endpoint' }), { status: 405, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } catch (error) { console.error('Upload error:', error) return new Response( JSON.stringify({ error: 'Internal server error', message: error instanceof Error ? error.message : 'An unexpected error occurred' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } })