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: '5mb', }, }, }; const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']; /** * POST — upload a background texture for a custom frame * DELETE — remove the frame's texture */ 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, texture_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 deleteStoredTexture = async () => { if (!frame.texture_url) return; try { await del(frame.texture_url); } catch (blobError) { console.warn('Failed to delete old frame texture:', blobError); } }; if (req.method === 'DELETE') { await deleteStoredTexture(); await sql` UPDATE custom_frames SET texture_url = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ${frameId} `; return res.status(200).json({ texture_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.texture; if (!file) { return res.status(400).json({ error: 'No texture file provided' }); } if (!ALLOWED_TYPES.includes(file.type)) { return res.status(400).json({ error: 'Invalid file type. Please upload a JPEG, PNG, or WebP image.', }); } if (file.size > 5 * 1024 * 1024) { return res.status(400).json({ error: 'File size must be less than 5MB' }); } await deleteStoredTexture(); const extension = file.type === 'image/jpeg' ? 'jpg' : file.type.split('/')[1]; const filename = `frame-textures/${user.userId}-${frameId}-${Date.now()}.${extension}`; const blob = await put(filename, file.buffer, { access: 'public', contentType: file.type, }); await sql` UPDATE custom_frames SET texture_url = ${blob.url}, updated_at = CURRENT_TIMESTAMP WHERE id = ${frameId} `; return res.status(200).json({ texture_url: blob.url }); } return res.status(405).json({ error: 'Method not allowed' }); } catch (error) { console.error('Frame texture API error:', error); return res.status(500).json({ error: 'Failed to handle frame texture' }); } } 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); }); }