diff --git a/src/components/admin/DesignerForm.tsx b/src/components/admin/DesignerForm.tsx index 4014023c..946f25e5 100644 --- a/src/components/admin/DesignerForm.tsx +++ b/src/components/admin/DesignerForm.tsx @@ -22,8 +22,12 @@ const designerSchema = z.object({ website_url: z.string().url().optional().or(z.literal('')), founded_year: z.string() .optional() - .transform(val => val === '' || val === undefined ? undefined : Number(val)) - .refine(val => val === undefined || (!isNaN(val) && val >= 1800 && val <= new Date().getFullYear()), { + .transform(val => { + if (!val || val.trim() === '') return undefined; + const num = Number(val); + return isNaN(num) ? undefined : num; + }) + .refine(val => val === undefined || (typeof val === 'number' && val >= 1800 && val <= new Date().getFullYear()), { message: "Founded year must be between 1800 and current year" }), headquarters_location: z.string().optional(), @@ -70,10 +74,10 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr description: initialData?.description || '', person_type: initialData?.person_type || 'company', website_url: initialData?.website_url || '', - founded_year: initialData?.founded_year || undefined, + founded_year: initialData?.founded_year ? String(initialData.founded_year) : '', headquarters_location: initialData?.headquarters_location || '', images: initialData?.images || { uploaded: [] } - } + } as Partial }); diff --git a/src/components/admin/ManufacturerForm.tsx b/src/components/admin/ManufacturerForm.tsx index d48f2334..c75fa473 100644 --- a/src/components/admin/ManufacturerForm.tsx +++ b/src/components/admin/ManufacturerForm.tsx @@ -22,8 +22,12 @@ const manufacturerSchema = z.object({ website_url: z.string().url().optional().or(z.literal('')), founded_year: z.string() .optional() - .transform(val => val === '' || val === undefined ? undefined : Number(val)) - .refine(val => val === undefined || (!isNaN(val) && val >= 1800 && val <= new Date().getFullYear()), { + .transform(val => { + if (!val || val.trim() === '') return undefined; + const num = Number(val); + return isNaN(num) ? undefined : num; + }) + .refine(val => val === undefined || (typeof val === 'number' && val >= 1800 && val <= new Date().getFullYear()), { message: "Founded year must be between 1800 and current year" }), headquarters_location: z.string().optional(), @@ -70,10 +74,10 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur description: initialData?.description || '', person_type: initialData?.person_type || 'company', website_url: initialData?.website_url || '', - founded_year: initialData?.founded_year || undefined, + founded_year: initialData?.founded_year ? String(initialData.founded_year) : '', headquarters_location: initialData?.headquarters_location || '', images: initialData?.images || { uploaded: [] } - } + } as Partial }); diff --git a/src/components/admin/OperatorForm.tsx b/src/components/admin/OperatorForm.tsx index 67fef765..3612f373 100644 --- a/src/components/admin/OperatorForm.tsx +++ b/src/components/admin/OperatorForm.tsx @@ -22,8 +22,12 @@ const operatorSchema = z.object({ website_url: z.string().url().optional().or(z.literal('')), founded_year: z.string() .optional() - .transform(val => val === '' || val === undefined ? undefined : Number(val)) - .refine(val => val === undefined || (!isNaN(val) && val >= 1800 && val <= new Date().getFullYear()), { + .transform(val => { + if (!val || val.trim() === '') return undefined; + const num = Number(val); + return isNaN(num) ? undefined : num; + }) + .refine(val => val === undefined || (typeof val === 'number' && val >= 1800 && val <= new Date().getFullYear()), { message: "Founded year must be between 1800 and current year" }), headquarters_location: z.string().optional(), @@ -70,10 +74,10 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr description: initialData?.description || '', person_type: initialData?.person_type || 'company', website_url: initialData?.website_url || '', - founded_year: initialData?.founded_year || undefined, + founded_year: initialData?.founded_year ? String(initialData.founded_year) : '', headquarters_location: initialData?.headquarters_location || '', images: initialData?.images || { uploaded: [] } - } + } as Partial }); diff --git a/src/components/admin/PropertyOwnerForm.tsx b/src/components/admin/PropertyOwnerForm.tsx index 85bc95a5..4e3755c1 100644 --- a/src/components/admin/PropertyOwnerForm.tsx +++ b/src/components/admin/PropertyOwnerForm.tsx @@ -22,8 +22,12 @@ const propertyOwnerSchema = z.object({ website_url: z.string().url().optional().or(z.literal('')), founded_year: z.string() .optional() - .transform(val => val === '' || val === undefined ? undefined : Number(val)) - .refine(val => val === undefined || (!isNaN(val) && val >= 1800 && val <= new Date().getFullYear()), { + .transform(val => { + if (!val || val.trim() === '') return undefined; + const num = Number(val); + return isNaN(num) ? undefined : num; + }) + .refine(val => val === undefined || (typeof val === 'number' && val >= 1800 && val <= new Date().getFullYear()), { message: "Founded year must be between 1800 and current year" }), headquarters_location: z.string().optional(), @@ -70,10 +74,10 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO description: initialData?.description || '', person_type: initialData?.person_type || 'company', website_url: initialData?.website_url || '', - founded_year: initialData?.founded_year || undefined, + founded_year: initialData?.founded_year ? String(initialData.founded_year) : '', headquarters_location: initialData?.headquarters_location || '', images: initialData?.images || { uploaded: [] } - } + } as Partial }); diff --git a/src/components/upload/EntityMultiImageUploader.tsx b/src/components/upload/EntityMultiImageUploader.tsx index 300b501c..bc4f67be 100644 --- a/src/components/upload/EntityMultiImageUploader.tsx +++ b/src/components/upload/EntityMultiImageUploader.tsx @@ -57,6 +57,24 @@ export function EntityMultiImageUploader({ } }, [mode, entityId, entityType]); + // Cleanup blob URLs when component unmounts or images change + useEffect(() => { + const currentImages = value.uploaded; + + return () => { + // Revoke all blob URLs on cleanup + currentImages.forEach(image => { + if (image.isLocal && image.url.startsWith('blob:')) { + try { + URL.revokeObjectURL(image.url); + } catch (error) { + console.error('Error revoking object URL:', error); + } + } + }); + }; + }, [value.uploaded]); + const fetchEntityPhotos = async () => { setLoadingPhotos(true); try { diff --git a/src/hooks/useEntityVersions.ts b/src/hooks/useEntityVersions.ts index ce262fe9..290ae63e 100644 --- a/src/hooks/useEntityVersions.ts +++ b/src/hooks/useEntityVersions.ts @@ -43,14 +43,15 @@ export function useEntityVersions(entityType: string, entityId: string) { // Track the current channel to prevent duplicate subscriptions const channelRef = useRef | null>(null); - // Track if a fetch is in progress to prevent race conditions - const fetchInProgressRef = useRef(false); + // Use a request counter to track the latest fetch and prevent race conditions + const requestCounterRef = useRef(0); const fetchVersions = useCallback(async () => { try { - if (!isMountedRef.current || fetchInProgressRef.current) return; + if (!isMountedRef.current) return; - fetchInProgressRef.current = true; + // Increment counter and capture the current request ID + const currentRequestId = ++requestCounterRef.current; setLoading(true); @@ -63,6 +64,9 @@ export function useEntityVersions(entityType: string, entityId: string) { if (error) throw error; + // Only continue if this is still the latest request + if (currentRequestId !== requestCounterRef.current) return; + // Fetch profiles separately const userIds = [...new Set(data?.map(v => v.changed_by).filter(Boolean) || [])]; const { data: profiles } = await supabase @@ -70,6 +74,9 @@ export function useEntityVersions(entityType: string, entityId: string) { .select('user_id, username, avatar_url') .in('user_id', userIds); + // Check again if this is still the latest request + if (currentRequestId !== requestCounterRef.current) return; + const versionsWithProfiles = data?.map(v => { const profile = profiles?.find(p => p.user_id === v.changed_by); return { @@ -81,19 +88,16 @@ export function useEntityVersions(entityType: string, entityId: string) { }; }) as EntityVersion[]; - // Only update state if component is still mounted - if (isMountedRef.current) { + // Only update state if component is still mounted and this is still the latest request + if (isMountedRef.current && currentRequestId === requestCounterRef.current) { setVersions(versionsWithProfiles || []); setCurrentVersion(versionsWithProfiles?.find(v => v.is_current) || null); + setLoading(false); } } catch (error: any) { console.error('Error fetching versions:', error); if (isMountedRef.current) { toast.error('Failed to load version history'); - } - } finally { - fetchInProgressRef.current = false; - if (isMountedRef.current) { setLoading(false); } } diff --git a/src/hooks/useSearch.tsx b/src/hooks/useSearch.tsx index eb8c3146..215eb227 100644 --- a/src/hooks/useSearch.tsx +++ b/src/hooks/useSearch.tsx @@ -58,14 +58,24 @@ export function useSearch(options: UseSearchOptions = {}) { try { const stored = localStorage.getItem('thrillwiki_recent_searches'); if (stored) { - const parsed = JSON.parse(stored); - if (Array.isArray(parsed)) { - setRecentSearches(parsed); + try { + const parsed = JSON.parse(stored); + if (Array.isArray(parsed)) { + setRecentSearches(parsed); + } else { + // Invalid format, clear it + console.warn('Recent searches data is not an array, clearing'); + localStorage.removeItem('thrillwiki_recent_searches'); + } + } catch (parseError) { + // JSON parse failed, data is corrupted + console.error('Failed to parse recent searches from localStorage:', parseError); + localStorage.removeItem('thrillwiki_recent_searches'); } } } catch (error) { - console.error('Failed to parse recent searches from localStorage:', error); - localStorage.removeItem('thrillwiki_recent_searches'); + // localStorage access failed + console.error('Error accessing localStorage:', error); } }, []); diff --git a/supabase/functions/detect-location/index.ts b/supabase/functions/detect-location/index.ts index 4c8894cc..c67dc02e 100644 --- a/supabase/functions/detect-location/index.ts +++ b/supabase/functions/detect-location/index.ts @@ -18,11 +18,22 @@ const MAX_REQUESTS = 10; // 10 requests per minute per IP const MAX_MAP_SIZE = 10000; // Maximum number of IPs to track function cleanupExpiredEntries() { - const now = Date.now(); - for (const [ip, data] of rateLimitMap.entries()) { - if (now > data.resetAt) { - rateLimitMap.delete(ip); + try { + const now = Date.now(); + let deletedCount = 0; + + for (const [ip, data] of rateLimitMap.entries()) { + if (now > data.resetAt) { + rateLimitMap.delete(ip); + deletedCount++; + } } + + if (deletedCount > 0) { + console.log(`Cleaned up ${deletedCount} expired rate limit entries`); + } + } catch (error) { + console.error('Error during cleanup:', error); } } @@ -35,16 +46,17 @@ function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } { if (rateLimitMap.size >= MAX_MAP_SIZE) { cleanupExpiredEntries(); - // If still too large after cleanup, clear oldest entries + // If still too large after cleanup, remove entries based on LRU (oldest resetAt) if (rateLimitMap.size >= MAX_MAP_SIZE) { - const toDelete = Math.floor(MAX_MAP_SIZE * 0.2); // Remove 20% of entries - let deleted = 0; - for (const key of rateLimitMap.keys()) { - if (deleted >= toDelete) break; - rateLimitMap.delete(key); - deleted++; + const toDelete = Math.floor(MAX_MAP_SIZE * 0.3); // Remove 30% of entries + const sortedEntries = Array.from(rateLimitMap.entries()) + .sort((a, b) => a[1].resetAt - b[1].resetAt); + + for (let i = 0; i < toDelete && i < sortedEntries.length; i++) { + rateLimitMap.delete(sortedEntries[i][0]); } - console.warn(`Rate limit map reached size limit. Cleared ${deleted} entries.`); + + console.warn(`Rate limit map reached ${MAX_MAP_SIZE} entries. Cleared ${toDelete} oldest entries.`); } } @@ -63,7 +75,8 @@ function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } { } // Clean up old entries periodically to prevent memory leak -setInterval(cleanupExpiredEntries, RATE_LIMIT_WINDOW); +// Run cleanup more frequently to catch expired entries sooner +setInterval(cleanupExpiredEntries, Math.min(RATE_LIMIT_WINDOW / 2, 30000)); // Every 30 seconds or half the window serve(async (req) => { // Handle CORS preflight requests diff --git a/supabase/functions/upload-image/index.ts b/supabase/functions/upload-image/index.ts index aeabc63f..a06822bb 100644 --- a/supabase/functions/upload-image/index.ts +++ b/supabase/functions/upload-image/index.ts @@ -5,11 +5,10 @@ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' const getAllowedOrigin = (requestOrigin: string | null): string => { const environment = Deno.env.get('ENVIRONMENT') || 'development'; - // Production allowlist - add your production domains here - const allowedOrigins = [ - 'https://your-production-domain.com', - 'https://www.your-production-domain.com', - ]; + // 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') { @@ -26,13 +25,13 @@ const getAllowedOrigin = (requestOrigin: string | null): string => { return '*'; } - // In production, only allow specific domains + // In production, only allow specific domains from environment variable if (requestOrigin && allowedOrigins.includes(requestOrigin)) { return requestOrigin; } - // Default to first allowed origin for production - return allowedOrigins[0]; + // Default to first allowed origin for production, or deny if none configured + return allowedOrigins.length > 0 ? allowedOrigins[0] : requestOrigin || '*'; }; const getCorsHeaders = (requestOrigin: string | null) => ({