import { Router } from 'express' import path from 'node:path' import fs from 'node:fs' import crypto from 'node:crypto' import { fileURLToPath } from 'node:url' import { adminAuth } from '../middleware/adminAuth.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const UPLOAD_DIR = path.join(__dirname, '..', '..', 'public', 'images') export const uploadRouter = Router() uploadRouter.use(adminAuth) // Ensure upload directory exists if (!fs.existsSync(UPLOAD_DIR)) { fs.mkdirSync(UPLOAD_DIR, { recursive: true }) } const MAX_SIZE = 5 * 1024 * 1024 // 5MB const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'] uploadRouter.post('/', async (req, res) => { try { const contentType = req.headers['content-type'] || '' if (!contentType.startsWith('multipart/form-data')) { res.status(400).json({ error: { code: 'INVALID_CONTENT_TYPE', message: 'Expected multipart/form-data' } }) return } // Parse multipart manually using web-standard approach const chunks: Buffer[] = [] let size = 0 for await (const chunk of req) { size += chunk.length if (size > MAX_SIZE) { res.status(413).json({ error: { code: 'FILE_TOO_LARGE', message: 'File must be under 5MB' } }) return } chunks.push(chunk) } const body = Buffer.concat(chunks) const boundary = contentType.split('boundary=')[1] if (!boundary) { res.status(400).json({ error: { code: 'NO_BOUNDARY', message: 'Missing multipart boundary' } }) return } // Find file data between boundaries const boundaryBuf = Buffer.from(`--${boundary}`) const parts = splitBuffer(body, boundaryBuf).filter((p) => p.length > 4) const files: string[] = [] for (const part of parts) { const headerEnd = part.indexOf('\r\n\r\n') if (headerEnd === -1) continue const headers = part.subarray(0, headerEnd).toString() const fileData = part.subarray(headerEnd + 4, part.length - 2) // trim trailing \r\n const filenameMatch = headers.match(/filename="([^"]+)"/) const typeMatch = headers.match(/Content-Type:\s*(\S+)/i) if (!filenameMatch || !typeMatch) continue const mimeType = typeMatch[1] if (!ALLOWED_TYPES.includes(mimeType)) continue const ext = path.extname(filenameMatch[1]).toLowerCase() || mimeExtension(mimeType) const filename = `${crypto.randomBytes(16).toString('hex')}${ext}` const filepath = path.join(UPLOAD_DIR, filename) fs.writeFileSync(filepath, fileData) files.push(`/images/${filename}`) } if (files.length === 0) { res.status(400).json({ error: { code: 'NO_FILES', message: 'No valid image files found' } }) return } res.json({ urls: files }) } catch (err) { console.error('Upload failed:', err) res.status(500).json({ error: { code: 'UPLOAD_FAILED', message: 'File upload failed' } }) } }) function mimeExtension(mime: string): string { const map: Record = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/avif': '.avif', } return map[mime] || '.jpg' } function splitBuffer(buf: Buffer, delimiter: Buffer): Buffer[] { const parts: Buffer[] = [] let start = 0 while (true) { const idx = buf.indexOf(delimiter, start) if (idx === -1) { if (start < buf.length) parts.push(buf.subarray(start)) break } if (idx > start) parts.push(buf.subarray(start, idx)) start = idx + delimiter.length } return parts }