Files
thrilltrack-explorer/supabase/functions/upload-image/index.ts
pac7 1addcbc0dd Improve error handling and environment configuration across the application
Enhance input validation, update environment variable usage for Supabase and Turnstile, and refine image upload and auth logic for better robustness and developer experience.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: cb061c75-702e-4b89-a8d1-77a96cdcdfbb
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7cdf4e95-3f41-4180-b8e3-8ef56d032c0e/cb061c75-702e-4b89-a8d1-77a96cdcdfbb/ANdRXVZ
2025-10-07 14:42:22 +00:00

277 lines
8.6 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'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
}
serve(async (req) => {
// 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 for delete operations' }),
{
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
// Verify JWT token
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')!
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
global: { headers: { Authorization: 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' }),
{
status: 401,
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 in request body' }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
const { imageId } = requestBody;
if (!imageId || typeof imageId !== 'string' || imageId.trim() === '') {
return new Response(
JSON.stringify({ error: 'imageId is required and must be a non-empty string' }),
{
status: 400,
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) {
console.error('Cloudflare delete error:', deleteResult)
return new Response(
JSON.stringify({
error: 'Failed to delete image',
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') {
// 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: '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))
}
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) {
console.error('Cloudflare direct upload error:', directUploadResult)
return new Response(
JSON.stringify({
error: 'Failed to get 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') {
// 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: 'id query parameter is required and must be non-empty' }),
{
status: 400,
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) {
console.error('Cloudflare image status error:', imageResult)
return new Response(
JSON.stringify({
error: 'Failed to get image status',
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' }),
{
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 : 'Unknown error'
}),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
})