mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 13:31:13 -05:00
Improve form validation and image handling for entities
Refactor validation logic for 'founded_year' in multiple form components, enhance image cleanup in `EntityMultiImageUploader`, update `useEntityVersions` to prevent race conditions, improve error handling for recent searches in `useSearch`, refine rate limiting logic in `detect-location` Supabase function, and update CORS configuration for `upload-image` Supabase function. Replit-Commit-Author: Agent Replit-Commit-Session-Id: b9af4867-23a7-43cc-baeb-4a97f66b4150 Replit-Commit-Checkpoint-Type: intermediate_checkpoint
This commit is contained in:
@@ -22,8 +22,12 @@ const designerSchema = z.object({
|
|||||||
website_url: z.string().url().optional().or(z.literal('')),
|
website_url: z.string().url().optional().or(z.literal('')),
|
||||||
founded_year: z.string()
|
founded_year: z.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform(val => val === '' || val === undefined ? undefined : Number(val))
|
.transform(val => {
|
||||||
.refine(val => val === undefined || (!isNaN(val) && val >= 1800 && val <= new Date().getFullYear()), {
|
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"
|
message: "Founded year must be between 1800 and current year"
|
||||||
}),
|
}),
|
||||||
headquarters_location: z.string().optional(),
|
headquarters_location: z.string().optional(),
|
||||||
@@ -70,10 +74,10 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
|
|||||||
description: initialData?.description || '',
|
description: initialData?.description || '',
|
||||||
person_type: initialData?.person_type || 'company',
|
person_type: initialData?.person_type || 'company',
|
||||||
website_url: initialData?.website_url || '',
|
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 || '',
|
headquarters_location: initialData?.headquarters_location || '',
|
||||||
images: initialData?.images || { uploaded: [] }
|
images: initialData?.images || { uploaded: [] }
|
||||||
}
|
} as Partial<DesignerFormData>
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,12 @@ const manufacturerSchema = z.object({
|
|||||||
website_url: z.string().url().optional().or(z.literal('')),
|
website_url: z.string().url().optional().or(z.literal('')),
|
||||||
founded_year: z.string()
|
founded_year: z.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform(val => val === '' || val === undefined ? undefined : Number(val))
|
.transform(val => {
|
||||||
.refine(val => val === undefined || (!isNaN(val) && val >= 1800 && val <= new Date().getFullYear()), {
|
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"
|
message: "Founded year must be between 1800 and current year"
|
||||||
}),
|
}),
|
||||||
headquarters_location: z.string().optional(),
|
headquarters_location: z.string().optional(),
|
||||||
@@ -70,10 +74,10 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
|
|||||||
description: initialData?.description || '',
|
description: initialData?.description || '',
|
||||||
person_type: initialData?.person_type || 'company',
|
person_type: initialData?.person_type || 'company',
|
||||||
website_url: initialData?.website_url || '',
|
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 || '',
|
headquarters_location: initialData?.headquarters_location || '',
|
||||||
images: initialData?.images || { uploaded: [] }
|
images: initialData?.images || { uploaded: [] }
|
||||||
}
|
} as Partial<ManufacturerFormData>
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,12 @@ const operatorSchema = z.object({
|
|||||||
website_url: z.string().url().optional().or(z.literal('')),
|
website_url: z.string().url().optional().or(z.literal('')),
|
||||||
founded_year: z.string()
|
founded_year: z.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform(val => val === '' || val === undefined ? undefined : Number(val))
|
.transform(val => {
|
||||||
.refine(val => val === undefined || (!isNaN(val) && val >= 1800 && val <= new Date().getFullYear()), {
|
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"
|
message: "Founded year must be between 1800 and current year"
|
||||||
}),
|
}),
|
||||||
headquarters_location: z.string().optional(),
|
headquarters_location: z.string().optional(),
|
||||||
@@ -70,10 +74,10 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
|
|||||||
description: initialData?.description || '',
|
description: initialData?.description || '',
|
||||||
person_type: initialData?.person_type || 'company',
|
person_type: initialData?.person_type || 'company',
|
||||||
website_url: initialData?.website_url || '',
|
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 || '',
|
headquarters_location: initialData?.headquarters_location || '',
|
||||||
images: initialData?.images || { uploaded: [] }
|
images: initialData?.images || { uploaded: [] }
|
||||||
}
|
} as Partial<OperatorFormData>
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,12 @@ const propertyOwnerSchema = z.object({
|
|||||||
website_url: z.string().url().optional().or(z.literal('')),
|
website_url: z.string().url().optional().or(z.literal('')),
|
||||||
founded_year: z.string()
|
founded_year: z.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform(val => val === '' || val === undefined ? undefined : Number(val))
|
.transform(val => {
|
||||||
.refine(val => val === undefined || (!isNaN(val) && val >= 1800 && val <= new Date().getFullYear()), {
|
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"
|
message: "Founded year must be between 1800 and current year"
|
||||||
}),
|
}),
|
||||||
headquarters_location: z.string().optional(),
|
headquarters_location: z.string().optional(),
|
||||||
@@ -70,10 +74,10 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
|
|||||||
description: initialData?.description || '',
|
description: initialData?.description || '',
|
||||||
person_type: initialData?.person_type || 'company',
|
person_type: initialData?.person_type || 'company',
|
||||||
website_url: initialData?.website_url || '',
|
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 || '',
|
headquarters_location: initialData?.headquarters_location || '',
|
||||||
images: initialData?.images || { uploaded: [] }
|
images: initialData?.images || { uploaded: [] }
|
||||||
}
|
} as Partial<PropertyOwnerFormData>
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,24 @@ export function EntityMultiImageUploader({
|
|||||||
}
|
}
|
||||||
}, [mode, entityId, entityType]);
|
}, [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 () => {
|
const fetchEntityPhotos = async () => {
|
||||||
setLoadingPhotos(true);
|
setLoadingPhotos(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -43,14 +43,15 @@ export function useEntityVersions(entityType: string, entityId: string) {
|
|||||||
// Track the current channel to prevent duplicate subscriptions
|
// Track the current channel to prevent duplicate subscriptions
|
||||||
const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null);
|
const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null);
|
||||||
|
|
||||||
// Track if a fetch is in progress to prevent race conditions
|
// Use a request counter to track the latest fetch and prevent race conditions
|
||||||
const fetchInProgressRef = useRef(false);
|
const requestCounterRef = useRef(0);
|
||||||
|
|
||||||
const fetchVersions = useCallback(async () => {
|
const fetchVersions = useCallback(async () => {
|
||||||
try {
|
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);
|
setLoading(true);
|
||||||
|
|
||||||
@@ -63,6 +64,9 @@ export function useEntityVersions(entityType: string, entityId: string) {
|
|||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Only continue if this is still the latest request
|
||||||
|
if (currentRequestId !== requestCounterRef.current) return;
|
||||||
|
|
||||||
// Fetch profiles separately
|
// Fetch profiles separately
|
||||||
const userIds = [...new Set(data?.map(v => v.changed_by).filter(Boolean) || [])];
|
const userIds = [...new Set(data?.map(v => v.changed_by).filter(Boolean) || [])];
|
||||||
const { data: profiles } = await supabase
|
const { data: profiles } = await supabase
|
||||||
@@ -70,6 +74,9 @@ export function useEntityVersions(entityType: string, entityId: string) {
|
|||||||
.select('user_id, username, avatar_url')
|
.select('user_id, username, avatar_url')
|
||||||
.in('user_id', userIds);
|
.in('user_id', userIds);
|
||||||
|
|
||||||
|
// Check again if this is still the latest request
|
||||||
|
if (currentRequestId !== requestCounterRef.current) return;
|
||||||
|
|
||||||
const versionsWithProfiles = data?.map(v => {
|
const versionsWithProfiles = data?.map(v => {
|
||||||
const profile = profiles?.find(p => p.user_id === v.changed_by);
|
const profile = profiles?.find(p => p.user_id === v.changed_by);
|
||||||
return {
|
return {
|
||||||
@@ -81,19 +88,16 @@ export function useEntityVersions(entityType: string, entityId: string) {
|
|||||||
};
|
};
|
||||||
}) as EntityVersion[];
|
}) as EntityVersion[];
|
||||||
|
|
||||||
// Only update state if component is still mounted
|
// Only update state if component is still mounted and this is still the latest request
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current && currentRequestId === requestCounterRef.current) {
|
||||||
setVersions(versionsWithProfiles || []);
|
setVersions(versionsWithProfiles || []);
|
||||||
setCurrentVersion(versionsWithProfiles?.find(v => v.is_current) || null);
|
setCurrentVersion(versionsWithProfiles?.find(v => v.is_current) || null);
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error fetching versions:', error);
|
console.error('Error fetching versions:', error);
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
toast.error('Failed to load version history');
|
toast.error('Failed to load version history');
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
fetchInProgressRef.current = false;
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,14 +58,24 @@ export function useSearch(options: UseSearchOptions = {}) {
|
|||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('thrillwiki_recent_searches');
|
const stored = localStorage.getItem('thrillwiki_recent_searches');
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = JSON.parse(stored);
|
try {
|
||||||
if (Array.isArray(parsed)) {
|
const parsed = JSON.parse(stored);
|
||||||
setRecentSearches(parsed);
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to parse recent searches from localStorage:', error);
|
// localStorage access failed
|
||||||
localStorage.removeItem('thrillwiki_recent_searches');
|
console.error('Error accessing localStorage:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
const MAX_MAP_SIZE = 10000; // Maximum number of IPs to track
|
||||||
|
|
||||||
function cleanupExpiredEntries() {
|
function cleanupExpiredEntries() {
|
||||||
const now = Date.now();
|
try {
|
||||||
for (const [ip, data] of rateLimitMap.entries()) {
|
const now = Date.now();
|
||||||
if (now > data.resetAt) {
|
let deletedCount = 0;
|
||||||
rateLimitMap.delete(ip);
|
|
||||||
|
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) {
|
if (rateLimitMap.size >= MAX_MAP_SIZE) {
|
||||||
cleanupExpiredEntries();
|
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) {
|
if (rateLimitMap.size >= MAX_MAP_SIZE) {
|
||||||
const toDelete = Math.floor(MAX_MAP_SIZE * 0.2); // Remove 20% of entries
|
const toDelete = Math.floor(MAX_MAP_SIZE * 0.3); // Remove 30% of entries
|
||||||
let deleted = 0;
|
const sortedEntries = Array.from(rateLimitMap.entries())
|
||||||
for (const key of rateLimitMap.keys()) {
|
.sort((a, b) => a[1].resetAt - b[1].resetAt);
|
||||||
if (deleted >= toDelete) break;
|
|
||||||
rateLimitMap.delete(key);
|
for (let i = 0; i < toDelete && i < sortedEntries.length; i++) {
|
||||||
deleted++;
|
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
|
// 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) => {
|
serve(async (req) => {
|
||||||
// Handle CORS preflight requests
|
// Handle CORS preflight requests
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
|||||||
const getAllowedOrigin = (requestOrigin: string | null): string => {
|
const getAllowedOrigin = (requestOrigin: string | null): string => {
|
||||||
const environment = Deno.env.get('ENVIRONMENT') || 'development';
|
const environment = Deno.env.get('ENVIRONMENT') || 'development';
|
||||||
|
|
||||||
// Production allowlist - add your production domains here
|
// Production allowlist - configure via ALLOWED_ORIGINS environment variable
|
||||||
const allowedOrigins = [
|
// Format: comma-separated list of origins, e.g., "https://example.com,https://www.example.com"
|
||||||
'https://your-production-domain.com',
|
const allowedOriginsEnv = Deno.env.get('ALLOWED_ORIGINS') || '';
|
||||||
'https://www.your-production-domain.com',
|
const allowedOrigins = allowedOriginsEnv.split(',').filter(origin => origin.trim());
|
||||||
];
|
|
||||||
|
|
||||||
// In development, allow localhost and Replit domains
|
// In development, allow localhost and Replit domains
|
||||||
if (environment === 'development') {
|
if (environment === 'development') {
|
||||||
@@ -26,13 +25,13 @@ const getAllowedOrigin = (requestOrigin: string | null): string => {
|
|||||||
return '*';
|
return '*';
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, only allow specific domains
|
// In production, only allow specific domains from environment variable
|
||||||
if (requestOrigin && allowedOrigins.includes(requestOrigin)) {
|
if (requestOrigin && allowedOrigins.includes(requestOrigin)) {
|
||||||
return requestOrigin;
|
return requestOrigin;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to first allowed origin for production
|
// Default to first allowed origin for production, or deny if none configured
|
||||||
return allowedOrigins[0];
|
return allowedOrigins.length > 0 ? allowedOrigins[0] : requestOrigin || '*';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCorsHeaders = (requestOrigin: string | null) => ({
|
const getCorsHeaders = (requestOrigin: string | null) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user