diff --git a/src/components/auth/TurnstileCaptcha.tsx b/src/components/auth/TurnstileCaptcha.tsx index 7c9abd1f..4a9a8c6b 100644 --- a/src/components/auth/TurnstileCaptcha.tsx +++ b/src/components/auth/TurnstileCaptcha.tsx @@ -18,7 +18,7 @@ export function TurnstileCaptcha({ onSuccess, onError, onExpire, - siteKey = "0x4AAAAAAAk8oZ8Z8Z8Z8Z8Z", // Default test key - replace in production + siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY, theme = 'auto', size = 'normal', className = '' @@ -82,12 +82,12 @@ export function TurnstileCaptcha({ } }, [loading]); - if (!siteKey || siteKey === "0x4AAAAAAAk8oZ8Z8Z8Z8Z8Z") { + if (!siteKey) { return ( - CAPTCHA is using test keys. Configure VITE_TURNSTILE_SITE_KEY for production. + CAPTCHA is not configured. Please set VITE_TURNSTILE_SITE_KEY environment variable. ); diff --git a/src/components/upload/PhotoUpload.tsx b/src/components/upload/PhotoUpload.tsx index ae85ef61..bee0f36c 100644 --- a/src/components/upload/PhotoUpload.tsx +++ b/src/components/upload/PhotoUpload.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback } from 'react'; +import { useState, useRef, useCallback, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; @@ -33,6 +33,7 @@ interface UploadedImage { url: string; filename: string; thumbnailUrl: string; + previewUrl?: string; } export function PhotoUpload({ @@ -53,6 +54,7 @@ export function PhotoUpload({ const [dragOver, setDragOver] = useState(false); const [error, setError] = useState(null); const fileInputRef = useRef(null); + const objectUrlsRef = useRef>(new Set()); const isAvatar = variant === 'avatar'; const isCompact = variant === 'compact'; @@ -60,6 +62,36 @@ export function PhotoUpload({ const totalImages = uploadedImages.length + existingPhotos.length; const canUploadMore = totalImages < actualMaxFiles; + useEffect(() => { + return () => { + objectUrlsRef.current.forEach(url => { + try { + URL.revokeObjectURL(url); + } catch (error) { + console.error('Error revoking object URL:', error); + } + }); + objectUrlsRef.current.clear(); + }; + }, []); + + const createObjectUrl = (file: File): string => { + const url = URL.createObjectURL(file); + objectUrlsRef.current.add(url); + return url; + }; + + const revokeObjectUrl = (url: string) => { + if (objectUrlsRef.current.has(url)) { + try { + URL.revokeObjectURL(url); + objectUrlsRef.current.delete(url); + } catch (error) { + console.error('Error revoking object URL:', error); + } + } + }; + const validateFile = (file: File): string | null => { // Check file size using configurable limit const maxSize = maxSizeMB * 1024 * 1024; @@ -76,85 +108,92 @@ export function PhotoUpload({ return null; }; - const uploadFile = async (file: File): Promise => { - // Step 1: Get direct upload URL from our edge function - const { data: uploadData, error: uploadError } = await supabase.functions.invoke('upload-image', { - body: { - metadata: { - filename: file.name, - size: file.size, - type: file.type, - uploadedAt: new Date().toISOString() - }, - variant: isAvatar ? 'avatar' : 'public' - } - }); - - if (uploadError) { - console.error('Upload URL error:', uploadError); - throw new Error(uploadError.message); - } - - if (!uploadData?.success) { - throw new Error(uploadData?.error || 'Failed to get upload URL'); - } - - const { uploadURL, id } = uploadData; - - // Step 2: Upload file directly to Cloudflare - const formData = new FormData(); - formData.append('file', file); - - const uploadResponse = await fetch(uploadURL, { - method: 'POST', - body: formData, - }); - - if (!uploadResponse.ok) { - throw new Error('Direct upload to Cloudflare failed'); - } - - // Step 3: Poll for upload completion and get final URLs - const maxAttempts = 60; // 30 seconds maximum wait with faster polling - let attempts = 0; - - while (attempts < maxAttempts) { - try { - // Use direct fetch with URL parameters instead of supabase.functions.invoke with body - const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://ydvtmnrszybqnbcqbdcy.supabase.co'; - const response = await fetch(`${supabaseUrl}/functions/v1/upload-image?id=${encodeURIComponent(id)}`, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token}`, - 'Content-Type': 'application/json' - } - }); - - if (response.ok) { - const statusData = await response.json(); - - if (statusData?.success && statusData.uploaded && statusData.urls) { - const imageUrl = isAvatar ? statusData.urls.avatar : statusData.urls.public; - const thumbUrl = isAvatar ? statusData.urls.avatar : statusData.urls.thumbnail; - - return { - id: statusData.id, - url: imageUrl, - filename: file.name, - thumbnailUrl: thumbUrl - }; - } + const uploadFile = async (file: File, previewUrl: string): Promise => { + try { + const { data: uploadData, error: uploadError } = await supabase.functions.invoke('upload-image', { + body: { + metadata: { + filename: file.name, + size: file.size, + type: file.type, + uploadedAt: new Date().toISOString() + }, + variant: isAvatar ? 'avatar' : 'public' } - } catch (error) { - console.error('Status poll error:', error); + }); + + if (uploadError) { + console.error('Upload URL error:', uploadError); + revokeObjectUrl(previewUrl); + throw new Error(uploadError.message); } - // Wait 500ms before checking again (faster polling) - await new Promise(resolve => setTimeout(resolve, 500)); - attempts++; - } + if (!uploadData?.success) { + revokeObjectUrl(previewUrl); + throw new Error(uploadData?.error || 'Failed to get upload URL'); + } - throw new Error('Upload timeout - image processing took too long'); + const { uploadURL, id } = uploadData; + + const formData = new FormData(); + formData.append('file', file); + + const uploadResponse = await fetch(uploadURL, { + method: 'POST', + body: formData, + }); + + if (!uploadResponse.ok) { + revokeObjectUrl(previewUrl); + throw new Error('Direct upload to Cloudflare failed'); + } + + const maxAttempts = 60; + let attempts = 0; + + while (attempts < maxAttempts) { + try { + const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://ydvtmnrszybqnbcqbdcy.supabase.co'; + const response = await fetch(`${supabaseUrl}/functions/v1/upload-image?id=${encodeURIComponent(id)}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token}`, + 'Content-Type': 'application/json' + } + }); + + if (response.ok) { + const statusData = await response.json(); + + if (statusData?.success && statusData.uploaded && statusData.urls) { + const imageUrl = isAvatar ? statusData.urls.avatar : statusData.urls.public; + const thumbUrl = isAvatar ? statusData.urls.avatar : statusData.urls.thumbnail; + + revokeObjectUrl(previewUrl); + + return { + id: statusData.id, + url: imageUrl, + filename: file.name, + thumbnailUrl: thumbUrl, + previewUrl: undefined + }; + } + } + } catch (error) { + console.error('Status poll error:', error); + } + + await new Promise(resolve => setTimeout(resolve, 500)); + attempts++; + } + + revokeObjectUrl(previewUrl); + throw new Error('Upload timeout - image processing took too long'); + } catch (error) { + revokeObjectUrl(previewUrl); + throw error; + } }; const handleFiles = async (files: FileList) => { @@ -172,7 +211,6 @@ export function PhotoUpload({ return; } - // Validate all files first for (const file of filesToUpload) { const validationError = validateFile(file); if (validationError) { @@ -186,8 +224,9 @@ export function PhotoUpload({ setError(null); onUploadStart?.(); + const previewUrls: string[] = []; + try { - // Delete old image first if this is an avatar update if (isAvatar && currentImageId) { try { await supabase.functions.invoke('upload-image', { @@ -196,23 +235,22 @@ export function PhotoUpload({ }); } catch (deleteError) { console.warn('Failed to delete old avatar:', deleteError); - // Continue with upload even if deletion fails } } const uploadPromises = filesToUpload.map(async (file, index) => { setUploadProgress((index / filesToUpload.length) * 100); - return uploadFile(file); + const previewUrl = createObjectUrl(file); + previewUrls.push(previewUrl); + return uploadFile(file, previewUrl); }); const results = await Promise.all(uploadPromises); if (isAvatar) { - // For avatars, replace all existing images setUploadedImages(results); onUploadComplete?.(results.map(img => img.url), results[0]?.id); } else { - // For regular uploads, append to existing images setUploadedImages(prev => [...prev, ...results]); const allUrls = [...existingPhotos, ...uploadedImages.map(img => img.url), ...results.map(img => img.url)]; onUploadComplete?.(allUrls); @@ -220,6 +258,7 @@ export function PhotoUpload({ setUploadProgress(100); } catch (error: any) { + previewUrls.forEach(url => revokeObjectUrl(url)); const errorMessage = error.message || 'Upload failed'; setError(errorMessage); onError?.(errorMessage); @@ -230,6 +269,11 @@ export function PhotoUpload({ }; const removeImage = (imageId: string) => { + const imageToRemove = uploadedImages.find(img => img.id === imageId); + if (imageToRemove?.previewUrl) { + revokeObjectUrl(imageToRemove.previewUrl); + } + setUploadedImages(prev => prev.filter(img => img.id !== imageId)); const updatedUrls = [...existingPhotos, ...uploadedImages.filter(img => img.id !== imageId).map(img => img.url)]; onUploadComplete?.(updatedUrls); diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index 5b81df34..54609196 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -1,7 +1,8 @@ -import React, { createContext, useContext, useEffect, useState } from 'react'; +import React, { createContext, useContext, useEffect, useState, useRef } from 'react'; import type { User, Session } from '@supabase/supabase-js'; import { supabase } from '@/integrations/supabase/client'; import type { Profile } from '@/types/database'; +import { toast } from '@/hooks/use-toast'; interface AuthContextType { user: User | null; @@ -23,6 +24,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [loading, setLoading] = useState(true); const [pendingEmail, setPendingEmail] = useState(null); const [previousEmail, setPreviousEmail] = useState(null); + + // Refs for lifecycle and cleanup management + const isMountedRef = useRef(true); + const profileFetchTimeoutRef = useRef(null); + const novuUpdateTimeoutRef = useRef(null); const fetchProfile = async (userId: string) => { try { @@ -34,12 +40,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (error && error.code !== 'PGRST116') { console.error('Error fetching profile:', error); + + // Show user-friendly error notification + if (isMountedRef.current) { + toast({ + title: "Profile Loading Error", + description: "Unable to load your profile. Please refresh the page or try again later.", + variant: "destructive", + }); + } return; } - setProfile(data as Profile); + // Only update state if component is still mounted + if (isMountedRef.current) { + setProfile(data as Profile); + } } catch (error) { console.error('Error fetching profile:', error); + + // Show user-friendly error notification + if (isMountedRef.current) { + toast({ + title: "Profile Loading Error", + description: "An unexpected error occurred while loading your profile.", + variant: "destructive", + }); + } } }; @@ -52,6 +79,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => { // Get initial session supabase.auth.getSession().then(({ data: { session } }) => { + if (!isMountedRef.current) return; + setSession(session); setUser(session?.user ?? null); if (session?.user) { @@ -64,6 +93,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const { data: { subscription }, } = supabase.auth.onAuthStateChange((event, session) => { + if (!isMountedRef.current) return; + const currentEmail = session?.user?.email; const newEmailPending = session?.user?.new_email; @@ -81,8 +112,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { currentEmail !== previousEmail && !newEmailPending ) { + // Clear any existing Novu update timeout + if (novuUpdateTimeoutRef.current) { + clearTimeout(novuUpdateTimeoutRef.current); + } + // Defer Novu update and notifications to avoid blocking auth - setTimeout(async () => { + novuUpdateTimeoutRef.current = setTimeout(async () => { + if (!isMountedRef.current) return; + try { // Update Novu subscriber with confirmed email const { notificationService } = await import('@/lib/notificationService'); @@ -119,6 +157,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } } catch (error) { console.error('Error updating Novu after email confirmation:', error); + } finally { + novuUpdateTimeoutRef.current = null; } }, 0); } @@ -129,18 +169,40 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } if (session?.user) { + // Clear any existing profile fetch timeout + if (profileFetchTimeoutRef.current) { + clearTimeout(profileFetchTimeoutRef.current); + } + // Defer profile fetch to avoid deadlock - setTimeout(() => { + profileFetchTimeoutRef.current = setTimeout(() => { + if (!isMountedRef.current) return; fetchProfile(session.user.id); + profileFetchTimeoutRef.current = null; }, 0); } else { - setProfile(null); + if (isMountedRef.current) { + setProfile(null); + } } setLoading(false); }); - return () => subscription.unsubscribe(); + return () => { + isMountedRef.current = false; + subscription.unsubscribe(); + + // Clear any pending timeouts + if (profileFetchTimeoutRef.current) { + clearTimeout(profileFetchTimeoutRef.current); + profileFetchTimeoutRef.current = null; + } + if (novuUpdateTimeoutRef.current) { + clearTimeout(novuUpdateTimeoutRef.current); + novuUpdateTimeoutRef.current = null; + } + }; }, []); const signOut = async () => { diff --git a/src/hooks/useEntityVersions.ts b/src/hooks/useEntityVersions.ts index 98cb470e..d13d85a1 100644 --- a/src/hooks/useEntityVersions.ts +++ b/src/hooks/useEntityVersions.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { toast } from 'sonner'; @@ -36,9 +36,17 @@ export function useEntityVersions(entityType: string, entityId: string) { const [currentVersion, setCurrentVersion] = useState(null); const [loading, setLoading] = useState(true); const [fieldHistory, setFieldHistory] = useState([]); + + // Track if component is mounted to prevent state updates after unmount + const isMountedRef = useRef(true); + + // Track the current channel to prevent duplicate subscriptions + const channelRef = useRef | null>(null); const fetchVersions = async () => { try { + if (!isMountedRef.current) return; + setLoading(true); const { data, error } = await supabase @@ -62,13 +70,20 @@ export function useEntityVersions(entityType: string, entityId: string) { changer_profile: profiles?.find(p => p.user_id === v.changed_by) })) as EntityVersion[]; - setVersions(versionsWithProfiles || []); - setCurrentVersion(versionsWithProfiles?.find(v => v.is_current) || null); + // Only update state if component is still mounted + if (isMountedRef.current) { + setVersions(versionsWithProfiles || []); + setCurrentVersion(versionsWithProfiles?.find(v => v.is_current) || null); + } } catch (error: any) { console.error('Error fetching versions:', error); - toast.error('Failed to load version history'); + if (isMountedRef.current) { + toast.error('Failed to load version history'); + } } finally { - setLoading(false); + if (isMountedRef.current) { + setLoading(false); + } } }; @@ -82,10 +97,14 @@ export function useEntityVersions(entityType: string, entityId: string) { if (error) throw error; - setFieldHistory(data as FieldChange[] || []); + if (isMountedRef.current) { + setFieldHistory(data as FieldChange[] || []); + } } catch (error: any) { console.error('Error fetching field history:', error); - toast.error('Failed to load field history'); + if (isMountedRef.current) { + toast.error('Failed to load field history'); + } } }; @@ -101,7 +120,9 @@ export function useEntityVersions(entityType: string, entityId: string) { return data; } catch (error: any) { console.error('Error comparing versions:', error); - toast.error('Failed to compare versions'); + if (isMountedRef.current) { + toast.error('Failed to compare versions'); + } return null; } }; @@ -121,12 +142,16 @@ export function useEntityVersions(entityType: string, entityId: string) { if (error) throw error; - toast.success('Successfully rolled back to previous version'); - await fetchVersions(); + if (isMountedRef.current) { + toast.success('Successfully rolled back to previous version'); + await fetchVersions(); + } return data; } catch (error: any) { console.error('Error rolling back version:', error); - toast.error('Failed to rollback version'); + if (isMountedRef.current) { + toast.error('Failed to rollback version'); + } return null; } }; @@ -147,11 +172,15 @@ export function useEntityVersions(entityType: string, entityId: string) { if (error) throw error; - await fetchVersions(); + if (isMountedRef.current) { + await fetchVersions(); + } return data; } catch (error: any) { console.error('Error creating version:', error); - toast.error('Failed to create version'); + if (isMountedRef.current) { + toast.error('Failed to create version'); + } return null; } }; @@ -164,6 +193,15 @@ export function useEntityVersions(entityType: string, entityId: string) { // Set up realtime subscription for version changes useEffect(() => { + if (!entityType || !entityId) return; + + // Clean up existing channel if any + if (channelRef.current) { + supabase.removeChannel(channelRef.current); + channelRef.current = null; + } + + // Create new channel const channel = supabase .channel('entity_versions_changes') .on( @@ -175,16 +213,35 @@ export function useEntityVersions(entityType: string, entityId: string) { filter: `entity_type=eq.${entityType},entity_id=eq.${entityId}` }, () => { - fetchVersions(); + if (isMountedRef.current) { + fetchVersions(); + } } ) .subscribe(); + channelRef.current = channel; + return () => { - supabase.removeChannel(channel); + // Ensure cleanup happens in all scenarios + if (channelRef.current) { + supabase.removeChannel(channelRef.current).catch((error) => { + console.error('Error removing channel:', error); + }); + channelRef.current = null; + } }; }, [entityType, entityId]); + // Set mounted ref on mount and cleanup on unmount + useEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + }; + }, []); + return { versions, currentVersion, diff --git a/src/integrations/supabase/client.ts b/src/integrations/supabase/client.ts index 8da037e4..7100569a 100644 --- a/src/integrations/supabase/client.ts +++ b/src/integrations/supabase/client.ts @@ -2,8 +2,14 @@ import { createClient } from '@supabase/supabase-js'; import type { Database } from './types'; -const SUPABASE_URL = "https://ydvtmnrszybqnbcqbdcy.supabase.co"; -const SUPABASE_PUBLISHABLE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4"; +const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL; +const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY; + +if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) { + throw new Error( + 'Missing Supabase environment variables. Please ensure VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY are set in your environment.' + ); +} // Import the supabase client like this: // import { supabase } from "@/integrations/supabase/client"; diff --git a/supabase/functions/create-novu-subscriber/index.ts b/supabase/functions/create-novu-subscriber/index.ts index f53a5996..aaf9822d 100644 --- a/supabase/functions/create-novu-subscriber/index.ts +++ b/supabase/functions/create-novu-subscriber/index.ts @@ -24,6 +24,48 @@ serve(async (req) => { const { subscriberId, email, firstName, lastName, phone, avatar, data } = await req.json(); + // Validate required fields + if (!subscriberId || typeof subscriberId !== 'string' || subscriberId.trim() === '') { + return new Response( + JSON.stringify({ + success: false, + error: 'subscriberId is required and must be a non-empty string', + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 400, + } + ); + } + + if (!email || typeof email !== 'string' || email.trim() === '') { + return new Response( + JSON.stringify({ + success: false, + error: 'email is required and must be a non-empty string', + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 400, + } + ); + } + + // Validate email format using regex + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return new Response( + JSON.stringify({ + success: false, + error: 'Invalid email format. Please provide a valid email address', + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 400, + } + ); + } + console.log('Creating Novu subscriber:', { subscriberId, email, firstName }); const subscriber = await novu.subscribers.identify(subscriberId, { diff --git a/supabase/functions/process-selective-approval/index.ts b/supabase/functions/process-selective-approval/index.ts index d20c47de..a8d330c1 100644 --- a/supabase/functions/process-selective-approval/index.ts +++ b/supabase/functions/process-selective-approval/index.ts @@ -56,6 +56,54 @@ serve(async (req) => { const { itemIds, userId, submissionId }: ApprovalRequest = await req.json(); + // UUID validation regex + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + // Validate itemIds + if (!itemIds || !Array.isArray(itemIds)) { + return new Response( + JSON.stringify({ error: 'itemIds is required and must be an array' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + if (itemIds.length === 0) { + return new Response( + JSON.stringify({ error: 'itemIds must be a non-empty array' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Validate userId + if (!userId || typeof userId !== 'string' || userId.trim() === '') { + return new Response( + JSON.stringify({ error: 'userId is required and must be a non-empty string' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + if (!uuidRegex.test(userId)) { + return new Response( + JSON.stringify({ error: 'userId must be a valid UUID format' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Validate submissionId + if (!submissionId || typeof submissionId !== 'string' || submissionId.trim() === '') { + return new Response( + JSON.stringify({ error: 'submissionId is required and must be a non-empty string' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + if (!uuidRegex.test(submissionId)) { + return new Response( + JSON.stringify({ error: 'submissionId must be a valid UUID format' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + console.log('Processing selective approval:', { itemIds, userId, submissionId }); // Fetch all items for the submission @@ -84,7 +132,13 @@ serve(async (req) => { // Topologically sort items by dependencies const sortedItems = topologicalSort(items); const dependencyMap = new Map(); - const approvalResults = []; + const approvalResults: Array<{ + itemId: string; + entityId?: string | null; + itemType: string; + success: boolean; + error?: string; +}> = []; // Process items in order for (const item of sortedItems) { diff --git a/supabase/functions/upload-image/index.ts b/supabase/functions/upload-image/index.ts index b51c33ff..04e3508a 100644 --- a/supabase/functions/upload-image/index.ts +++ b/supabase/functions/upload-image/index.ts @@ -55,11 +55,24 @@ serve(async (req) => { } // Delete image from Cloudflare - const { imageId } = await req.json() - - if (!imageId) { + let requestBody; + try { + requestBody = await req.json(); + } catch (error) { return new Response( - JSON.stringify({ error: 'Image ID is required for deletion' }), + 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' } @@ -103,7 +116,25 @@ serve(async (req) => { if (req.method === 'POST') { // Request a direct upload URL from Cloudflare - const { metadata = {}, variant = 'public', requireSignedURLs = false } = await req.json().catch(() => ({})) + 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() @@ -159,9 +190,9 @@ serve(async (req) => { const url = new URL(req.url) const imageId = url.searchParams.get('id') - if (!imageId) { + if (!imageId || imageId.trim() === '') { return new Response( - JSON.stringify({ error: 'Image ID is required' }), + JSON.stringify({ error: 'id query parameter is required and must be non-empty' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' }