import fs from 'fs/promises'; import path from 'path'; import crypto from 'crypto'; const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'; const MAX_SIZE = 10 * 1024 * 1024; // 10MB const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; const MAGIC_BYTES: Record = { 'image/jpeg': [0xff, 0xd8, 0xff], 'image/png': [0x89, 0x50, 0x4e, 0x47], 'image/gif': [0x47, 0x49, 0x46], 'image/webp': [0x52, 0x49, 0x46, 0x46] }; function validateMagicBytes(buffer: Buffer, mimeType: string): boolean { const expected = MAGIC_BYTES[mimeType]; if (!expected) return false; for (let i = 0; i < expected.length; i++) { if (buffer[i] !== expected[i]) return false; } return true; } export async function saveUpload( file: File, subdir = 'general' ): Promise<{ url: string; path: string }> { if (file.size > MAX_SIZE) { throw new Error('File too large (max 10MB)'); } if (!ALLOWED_TYPES.includes(file.type)) { throw new Error('File type not allowed'); } const buffer = Buffer.from(await file.arrayBuffer()); if (!validateMagicBytes(buffer, file.type)) { throw new Error('File content does not match declared type'); } const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg'; const filename = `${crypto.randomUUID()}.${ext}`; const dir = path.join(UPLOAD_DIR, subdir); await fs.mkdir(dir, { recursive: true }); const filePath = path.join(dir, filename); await fs.writeFile(filePath, buffer); return { url: `/uploads/${subdir}/${filename}`, path: filePath }; } export async function deleteUpload(url: string): Promise { if (!url.startsWith('/uploads/')) return; const filePath = path.join(UPLOAD_DIR, url.replace('/uploads/', '')); try { await fs.unlink(filePath); } catch { // File already gone } }