Files
thrilltrack-explorer/supabase/functions/upload-image/index.ts
gpt-engineer-app[bot] 2d65f13b85 Connect to Lovable Cloud
Add centralized errorFormatter to convert various error types into readable messages, and apply it across edge functions. Replace String(error) usage with formatEdgeError, update relevant imports, fix a throw to use toError, and enhance logger to log formatted errors. Includes new errorFormatter.ts and widespread updates to 18+ edge functions plus logger integration.
2025-11-10 18:09:15 +00:00

737 lines
24 KiB
TypeScript

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'
import { formatEdgeError } from '../_shared/errorFormatter.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<string, string> => {
// 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<void> {
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: formatEdgeError(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 = 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 { 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)));