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,
  };
}