import { put, del } from '../../../../lib/object-storage.js'; import { sql } from '../../../../lib/sql.js'; import { getUserFromRequest } from '../../../../lib/permission-middleware'; import { checkUploadRateLimit } from '../../../../lib/rate-limit.js'; export const config = { api: { bodyParser: { sizeLimit: '8mb', }, }, }; const ALLOWED_TYPES = ['image/png', 'image/webp']; /** * POST — upload the full-card frame artwork for a custom frame * (PNG/WebP with transparent art + text windows) * DELETE — remove the frame image */ export default async function handler(req, res) { try { const user = await getUserFromRequest(req); if (!user) { return res.status(401).json({ error: 'Authentication required' }); } const frameId = parseInt(req.query.id, 10); if (!Number.isInteger(frameId)) { return res.status(400).json({ error: 'Invalid frame id' }); } const found = await sql` SELECT id, frame_image_url FROM custom_frames WHERE id = ${frameId} AND user_id = ${user.userId} `; if (found.rows.length === 0) { return res.status(404).json({ error: 'Frame not found' }); } const frame = found.rows[0]; const deleteStoredImage = async () => { if (!frame.frame_image_url) return; try { await del(frame.frame_image_url); } catch (blobError) { console.warn('Failed to delete old frame image:', blobError); } }; if (req.method === 'DELETE') { await deleteStoredImage(); await sql` UPDATE custom_frames SET frame_image_url = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ${frameId} `; return res.status(200).json({ frame_image_url: null }); } if (req.method === 'POST') { const { allowed, reset } = await checkUploadRateLimit(req, user.userId); if (!allowed) { res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000)); return res.status(429).json({ error: 'Too many attempts. Try again later.' }); } const contentType = req.headers['content-type']; if (!contentType || !contentType.startsWith('multipart/form-data')) { return res.status(400).json({ error: 'Content-Type must be multipart/form-data' }); } const formData = await parseMultipartFormData(req); const file = formData.image; if (!file) { return res.status(400).json({ error: 'No frame image provided' }); } if (!ALLOWED_TYPES.includes(file.type)) { return res.status(400).json({ error: 'Invalid file type. Please upload a PNG or WebP image (transparency required).', }); } if (file.size > 8 * 1024 * 1024) { return res.status(400).json({ error: 'File size must be less than 8MB' }); } await deleteStoredImage(); const extension = file.type.split('/')[1]; const filename = `frame-images/${user.userId}-${frameId}-${Date.now()}.${extension}`; const blob = await put(filename, file.buffer, { access: 'public', contentType: file.type, }); await sql` UPDATE custom_frames SET frame_image_url = ${blob.url}, updated_at = CURRENT_TIMESTAMP WHERE id = ${frameId} `; return res.status(200).json({ frame_image_url: blob.url }); } return res.status(405).json({ error: 'Method not allowed' }); } catch (error) { console.error('Frame image API error:', error); return res.status(500).json({ error: 'Failed to handle frame image' }); } } async function parseMultipartFormData(req) { return new Promise((resolve, reject) => { const chunks = []; req.on('data', (chunk) => { chunks.push(chunk); }); req.on('end', () => { try { const buffer = Buffer.concat(chunks); const boundary = req.headers['content-type'].split('boundary=')[1]; const parts = buffer.toString('binary').split(`--${boundary}`); const formData = {}; for (const part of parts) { if (part.includes('Content-Disposition: form-data')) { const nameMatch = part.match(/name="([^"]+)"/); const filenameMatch = part.match(/filename="([^"]+)"/); const contentTypeMatch = part.match(/Content-Type: ([^\r\n]+)/); if (nameMatch) { const fieldName = nameMatch[1]; const headerEndIndex = part.indexOf('\r\n\r\n'); if (headerEndIndex !== -1) { const content = part.substring(headerEndIndex + 4); const contentBuffer = Buffer.from(content, 'binary'); if (filenameMatch && contentTypeMatch) { formData[fieldName] = { originalName: filenameMatch[1], type: contentTypeMatch[1].trim(), buffer: contentBuffer.slice(0, -2), size: contentBuffer.length - 2, }; } else { formData[fieldName] = content.trim(); } } } } } resolve(formData); } catch (error) { reject(error); } }); req.on('error', reject); }); }