import { createClient, SupabaseClient } from '@supabase/supabase-js'; import { v4 as uuidv4 } from 'uuid'; import { CONFIG } from '../config'; import { logger } from '../utils/logger'; // ── Singleton client ────────────────────────────────────────────────────────── let _client: SupabaseClient | null = null; function getClient(): SupabaseClient { if (!_client) { _client = createClient(CONFIG.SUPABASE_URL, CONFIG.SUPABASE_SERVICE_KEY, { auth: { persistSession: false }, }); } return _client; } // ── Allowed MIME types ──────────────────────────────────────────────────────── const ALLOWED_MIME: Set = new Set([ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'video/mp4', 'video/webm', 'audio/mpeg', 'audio/ogg', 'audio/webm', 'application/pdf', ]); // ── File validation ─────────────────────────────────────────────────────────── export interface FileUploadRequest { fileName: string; mimeType: string; sizeBytes: number; } export interface UploadUrlResult { signedUrl: string; objectPath: string; publicUrl: string; } export function validateFileRequest(req: FileUploadRequest): string | null { if (!ALLOWED_MIME.has(req.mimeType)) { return `MIME type ${req.mimeType} is not allowed`; } if (req.sizeBytes > CONFIG.MAX_FILE_SIZE_BYTES) { return `File exceeds ${CONFIG.MAX_FILE_SIZE_BYTES / 1024 / 1024} MB limit`; } // Strip path traversal from filename const safeName = req.fileName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128); if (!safeName) return 'Invalid file name'; return null; } export function validateFileCount(count: number): string | null { if (count > CONFIG.MAX_FILE_COUNT) { return `Cannot upload more than ${CONFIG.MAX_FILE_COUNT} files at once`; } if (count < 1) return 'File count must be at least 1'; return null; } // ── Signed upload URL generation ───────────────────────────────────────────── /** * Generates a Supabase signed upload URL. * The client uses this to PUT the file directly to Supabase — HF server * never buffers any bytes, keeping memory usage at ~0 for file transfers. * * URL expires in 60 seconds — client must begin upload immediately. */ export async function generateUploadUrl( uploaderUid: string, req: FileUploadRequest ): Promise { const supabase = getClient(); // Sanitize file name const safeName = req.fileName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128); const ext = safeName.split('.').pop() ?? 'bin'; // Unique path per user prevents collisions and leakage const objectPath = `users/${uploaderUid}/${uuidv4()}.${ext}`; const { data, error } = await supabase.storage .from(CONFIG.SUPABASE_BUCKET) .createSignedUploadUrl(objectPath); if (error || !data) { logger.error('Supabase signed URL failed', { error: error?.message }); throw new Error('Could not generate upload URL'); } // Derive the public URL (bucket must be public, or use signed read URL) const { data: urlData } = supabase.storage .from(CONFIG.SUPABASE_BUCKET) .getPublicUrl(objectPath); return { signedUrl: data.signedUrl, objectPath, publicUrl: urlData.publicUrl, }; }