File size: 3,663 Bytes
87cb242 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | 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<string> = 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<UploadUrlResult> {
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,
};
}
|