chatapi / src /services /supabaseService.ts
wilooper's picture
initial commit
87cb242
Raw History Blame Contribute Delete
3.66 kB
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,
};
}