import api from "./api"; import { resizeImage, getVariantKey, getSizeSuffix, } from "../utils/imageResizer"; /** * Get the public URL for an image (S3 only) */ export const getPublicImageUrl = ( imagePath: string | null | undefined ): string => { if (!imagePath) return ""; // Already a full S3 URL if (imagePath.startsWith("https://") && imagePath.includes("s3.")) { return imagePath; } // S3 key (e.g., "profiles/uuid.jpg", "items/uuid.jpg", "forum/uuid.jpg") const s3Bucket = process.env.REACT_APP_S3_BUCKET || ""; const s3Region = process.env.REACT_APP_AWS_REGION || "us-east-1"; return `https://${s3Bucket}.s3.${s3Region}.amazonaws.com/${imagePath}`; }; export interface PresignedUrlResponse { uploadUrl: string; key: string; publicUrl: string; expiresAt: string; } export type UploadType = | "profile" | "item" | "message" | "forum" | "condition-check"; interface UploadOptions { onProgress?: (percent: number) => void; maxRetries?: number; } /** * Get a presigned URL for uploading a single file */ export async function getPresignedUrl( uploadType: UploadType, file: File ): Promise { const response = await api.post("/upload/presign", { uploadType, contentType: file.type, fileName: file.name, fileSize: file.size, }); return response.data; } /** * Get presigned URLs for uploading multiple files */ export async function getPresignedUrls( uploadType: UploadType, files: File[] ): Promise { const response = await api.post("/upload/presign-batch", { uploadType, files: files.map((f) => ({ contentType: f.type, fileName: f.name, fileSize: f.size, })), }); return response.data.uploads; } /** * Upload a file directly to S3 using a presigned URL */ export async function uploadToS3( file: File, uploadUrl: string, options: UploadOptions = {} ): Promise { const { onProgress, maxRetries = 3 } = options; for (let attempt = 0; attempt < maxRetries; attempt++) { try { await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("PUT", uploadUrl, true); xhr.setRequestHeader("Content-Type", file.type); if (onProgress) { xhr.upload.onprogress = (e) => { if (e.lengthComputable) { onProgress(Math.round((e.loaded / e.total) * 100)); } }; } xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { reject(new Error(`HTTP ${xhr.status}`)); } }; xhr.onerror = () => reject(new Error("Network error")); xhr.send(file); }); return; } catch (error) { if (attempt === maxRetries - 1) throw error; // Exponential backoff await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000)); } } } /** * Confirm that files have been uploaded to S3 */ export async function confirmUploads( keys: string[] ): Promise<{ confirmed: string[]; total: number }> { const response = await api.post("/upload/confirm", { keys }); return response.data; } /** * Upload a single file to S3 (complete flow) */ export async function uploadFile( uploadType: UploadType, file: File, options: UploadOptions = {} ): Promise<{ key: string; publicUrl: string }> { // Get presigned URL const presigned = await getPresignedUrl(uploadType, file); // Upload to S3 await uploadToS3(file, presigned.uploadUrl, options); // Confirm upload const { confirmed } = await confirmUploads([presigned.key]); if (confirmed.length === 0) { throw new Error("Upload verification failed"); } return { key: presigned.key, publicUrl: presigned.publicUrl }; } /** * Get a signed URL for accessing private content (messages, condition-checks) */ export async function getSignedUrl(key: string): Promise { const response = await api.get( `/upload/signed-url/${encodeURIComponent(key)}` ); return response.data.url; } /** * Get a signed URL for a specific image size variant (private content) * Backend will validate ownership using the base key */ export async function getSignedImageUrl( baseKey: string, size: "thumbnail" | "medium" | "original" = "original" ): Promise { const suffix = getSizeSuffix(size); const variantKey = getVariantKey(baseKey, suffix); return getSignedUrl(variantKey); } /** * Get URL for a specific image size variant * Falls back to original if variant doesn't exist (backward compatibility) */ export function getImageUrl( baseKey: string | null | undefined, size: "thumbnail" | "medium" | "original" = "original" ): string { if (!baseKey) return ""; const suffix = getSizeSuffix(size); const variantKey = getVariantKey(baseKey, suffix); return getPublicImageUrl(variantKey); } export interface UploadWithResizeOptions extends UploadOptions { skipResize?: boolean; } /** * Upload a single image with all size variants (thumbnail, medium, original) * Returns the base key (original, without suffix) for database storage */ export async function uploadImageWithVariants( uploadType: UploadType, file: File, options: UploadWithResizeOptions = {} ): Promise<{ baseKey: string; publicUrl: string; variants: string[] }> { const { onProgress, skipResize } = options; // If skipping resize, use regular upload if (skipResize) { const result = await uploadFile(uploadType, file, { onProgress }); return { baseKey: result.key, publicUrl: result.publicUrl, variants: [result.key] }; } // Generate resized variants const resizedImages = await resizeImage(file); if (resizedImages.length === 0) { throw new Error("Failed to resize image"); } // Get presigned URLs for all variants const files = resizedImages.map((r) => r.file); const presignedUrls = await getPresignedUrls(uploadType, files); // Upload all variants in parallel with combined progress const totalBytes = files.reduce((sum, f) => sum + f.size, 0); let uploadedBytes = 0; await Promise.all( files.map((variantFile, i) => uploadToS3(variantFile, presignedUrls[i].uploadUrl, { onProgress: (percent) => { if (onProgress) { const fileContribution = (variantFile.size / totalBytes) * percent; // Approximate combined progress onProgress(Math.min(99, Math.round(uploadedBytes / totalBytes * 100 + fileContribution))); } }, }).then(() => { uploadedBytes += files[i].size; }) ) ); // Confirm all uploads const keys = presignedUrls.map((p) => p.key); await confirmUploads(keys); // Find the original variant key (no suffix) for database storage const originalIdx = resizedImages.findIndex((r) => r.variant.size === "original"); const baseKey = presignedUrls[originalIdx]?.key || presignedUrls[0].key; if (onProgress) onProgress(100); return { baseKey, publicUrl: getPublicImageUrl(baseKey), variants: keys, }; } /** * Upload multiple images with all size variants * Returns array of base keys for database storage */ export async function uploadImagesWithVariants( uploadType: UploadType, files: File[], options: UploadWithResizeOptions = {} ): Promise<{ baseKey: string; publicUrl: string }[]> { if (files.length === 0) return []; const results = await Promise.all( files.map((file) => uploadImageWithVariants(uploadType, file, options)) ); return results.map((r) => ({ baseKey: r.baseKey, publicUrl: r.publicUrl })); }