165 lines
5.1 KiB
JavaScript
165 lines
5.1 KiB
JavaScript
|
|
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);
|
||
|
|
});
|
||
|
|
}
|