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
This commit is contained in:
pac7
2025-10-07 14:42:22 +00:00
parent 01c2df62a8
commit 1addcbc0dd
8 changed files with 412 additions and 116 deletions

View File

@@ -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<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const objectUrlsRef = useRef<Set<string>>(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<UploadedImage> => {
// 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<UploadedImage> => {
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);