Files
thrilltrack-explorer/supabase/functions/upload-image/index.ts
gpt-engineer-app[bot] e28dc97d71 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.
2025-11-11 03:03:26 +00:00

354 lines
11 KiB
TypeScript

import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { getAllowedOrigin, getCorsHeaders } from '../_shared/cors.ts'
import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.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) => {
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
}
}
// Apply strict rate limiting (5 requests/minute) to prevent abuse
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);
// 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' }
}
);
}
const corsHeaders = getCorsHeaders(allowedOrigin);
context.span.setAttribute('http_method', req.method);
context.span.setAttribute('action', 'upload_image');
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' }
}
)
}
const supabase = createAuthenticatedSupabaseClient(authHeader)
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
throw new Error('Invalid authentication');
}
context.span.setAttribute('user_id', user.id);
// Check if user is banned
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', user.id)
.single()
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.'
}),
{
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
// Delete image from Cloudflare
const requestBody = await req.json();
const { imageId } = requestBody;
validateString(imageId, 'imageId', { userId: user.id, requestId: context.requestId });
// Validate imageId format
const validImageIdPattern = /^[a-zA-Z0-9_-]{1,100}$/;
if (!validImageIdPattern.test(imageId)) {
throw new Error('Invalid imageId format - must be alphanumeric with optional hyphens/underscores (max 100 chars)');
}
addSpanEvent(context.span, 'delete_image_start', { imageId });
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) {
throw new Error(deleteResult.errors?.[0]?.message || deleteResult.error || 'Failed to delete image');
}
addSpanEvent(context.span, 'image_deleted', { imageId });
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' }
}
)
}
const supabase = createAuthenticatedSupabaseClient(authHeader)
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
throw new Error('Invalid authentication');
}
context.span.setAttribute('user_id', user.id);
// Check if user is banned
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', user.id)
.single()
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: '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: unknown) {
requestBody = {};
}
const { metadata = {}, variant = 'public', requireSignedURLs = false } = requestBody;
addSpanEvent(context.span, 'upload_url_request_start');
// Create FormData for the request
const formData = new FormData()
formData.append('requireSignedURLs', requireSignedURLs.toString())
if (metadata && Object.keys(metadata).length > 0) {
formData.append('metadata', JSON.stringify(metadata))
}
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,
}
)
const directUploadResult = await directUploadResponse.json()
if (!directUploadResponse.ok) {
throw new Error(directUploadResult.errors?.[0]?.message || directUploadResult.error || 'Unable to create upload URL');
}
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,
}),
{ 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' }
}
)
}
const supabase = createAuthenticatedSupabaseClient(authHeader)
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
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')
validateString(imageId, 'id', { userId: user.id, requestId: context.requestId });
addSpanEvent(context.span, 'get_image_status_start', { imageId });
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) {
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 baseUrl = `https://cdn.thrillwiki.com/images/${result.id}`
addSpanEvent(context.span, 'image_status_retrieved', { imageId: result.id });
return new Response(
JSON.stringify({
success: true,
id: result.id,
uploaded: result.uploaded,
variants: result.variants,
draft: result.draft,
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' }
}
)
}
);
export default withRateLimit(handler, rateLimiters.strict, {} as any);