209 lines
6.3 KiB
JavaScript
209 lines
6.3 KiB
JavaScript
|
|
import { put, del } from '@vercel/blob';
|
||
|
|
import { sql } from '@vercel/postgres';
|
||
|
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||
|
|
|
||
|
|
export const config = {
|
||
|
|
api: {
|
||
|
|
bodyParser: {
|
||
|
|
sizeLimit: '5mb',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
export default async function handler(req, res) {
|
||
|
|
try {
|
||
|
|
// Get authenticated user
|
||
|
|
const user = await getUserFromRequest(req);
|
||
|
|
if (!user) {
|
||
|
|
return res.status(401).json({ error: 'Authentication required' });
|
||
|
|
}
|
||
|
|
|
||
|
|
if (req.method === 'POST') {
|
||
|
|
// Handle avatar upload
|
||
|
|
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' });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse multipart form data
|
||
|
|
const formData = await parseMultipartFormData(req);
|
||
|
|
const file = formData.avatar;
|
||
|
|
|
||
|
|
if (!file) {
|
||
|
|
return res.status(400).json({ error: 'No avatar file provided' });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate file type
|
||
|
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||
|
|
if (!allowedTypes.includes(file.type)) {
|
||
|
|
return res.status(400).json({
|
||
|
|
error: 'Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate file size (5MB limit)
|
||
|
|
if (file.size > 5 * 1024 * 1024) {
|
||
|
|
return res.status(400).json({ error: 'File size must be less than 5MB' });
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Delete old avatar if exists
|
||
|
|
await deleteOldAvatar(user.userId);
|
||
|
|
|
||
|
|
// Generate unique filename
|
||
|
|
const fileExtension = file.type.split('/')[1];
|
||
|
|
const filename = `avatars/${user.userId}-${Date.now()}.${fileExtension}`;
|
||
|
|
|
||
|
|
// Upload to Vercel Blob
|
||
|
|
const blob = await put(filename, file.buffer, {
|
||
|
|
access: 'public',
|
||
|
|
contentType: file.type,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Save avatar info to database
|
||
|
|
await sql`
|
||
|
|
INSERT INTO user_avatars (user_id, filename, original_name, mime_type, file_size, file_path, is_active)
|
||
|
|
VALUES (${user.userId}, ${filename}, ${file.originalName}, ${file.type}, ${file.size}, ${blob.url}, true)
|
||
|
|
`;
|
||
|
|
|
||
|
|
// Update user's avatar_url
|
||
|
|
await sql`
|
||
|
|
UPDATE users
|
||
|
|
SET avatar_url = ${blob.url}, updated_at = CURRENT_TIMESTAMP
|
||
|
|
WHERE id = ${user.userId}
|
||
|
|
`;
|
||
|
|
|
||
|
|
res.status(200).json({
|
||
|
|
message: 'Avatar uploaded successfully',
|
||
|
|
avatar_url: blob.url
|
||
|
|
});
|
||
|
|
|
||
|
|
} catch (uploadError) {
|
||
|
|
console.error('Avatar upload error:', uploadError);
|
||
|
|
res.status(500).json({ error: 'Failed to upload avatar' });
|
||
|
|
}
|
||
|
|
|
||
|
|
} else if (req.method === 'DELETE') {
|
||
|
|
// Handle avatar deletion
|
||
|
|
try {
|
||
|
|
await deleteOldAvatar(user.userId);
|
||
|
|
|
||
|
|
// Clear user's avatar_url
|
||
|
|
await sql`
|
||
|
|
UPDATE users
|
||
|
|
SET avatar_url = NULL, updated_at = CURRENT_TIMESTAMP
|
||
|
|
WHERE id = ${user.userId}
|
||
|
|
`;
|
||
|
|
|
||
|
|
res.status(200).json({ message: 'Avatar deleted successfully' });
|
||
|
|
|
||
|
|
} catch (deleteError) {
|
||
|
|
console.error('Avatar deletion error:', deleteError);
|
||
|
|
res.status(500).json({ error: 'Failed to delete avatar' });
|
||
|
|
}
|
||
|
|
|
||
|
|
} else {
|
||
|
|
res.status(405).json({ error: 'Method not allowed' });
|
||
|
|
}
|
||
|
|
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Avatar API error:', error);
|
||
|
|
res.status(500).json({ error: 'Internal server error' });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Parse multipart form data manually
|
||
|
|
*/
|
||
|
|
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) {
|
||
|
|
// This is a file field
|
||
|
|
formData[fieldName] = {
|
||
|
|
originalName: filenameMatch[1],
|
||
|
|
type: contentTypeMatch[1],
|
||
|
|
buffer: contentBuffer.slice(0, -2), // Remove trailing \r\n
|
||
|
|
size: contentBuffer.length - 2
|
||
|
|
};
|
||
|
|
} else {
|
||
|
|
// This is a regular field
|
||
|
|
formData[fieldName] = content.trim();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
resolve(formData);
|
||
|
|
} catch (error) {
|
||
|
|
reject(error);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
req.on('error', reject);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Delete old avatar from Vercel Blob and database
|
||
|
|
*/
|
||
|
|
async function deleteOldAvatar(userId) {
|
||
|
|
try {
|
||
|
|
// Get current active avatar
|
||
|
|
const avatarResult = await sql`
|
||
|
|
SELECT file_path, filename FROM user_avatars
|
||
|
|
WHERE user_id = ${userId} AND is_active = true
|
||
|
|
`;
|
||
|
|
|
||
|
|
if (avatarResult.rows.length > 0) {
|
||
|
|
const avatar = avatarResult.rows[0];
|
||
|
|
|
||
|
|
// Delete from Vercel Blob
|
||
|
|
try {
|
||
|
|
await del(avatar.file_path);
|
||
|
|
} catch (blobError) {
|
||
|
|
console.warn('Failed to delete blob file:', blobError);
|
||
|
|
// Continue anyway - the database record should still be cleaned up
|
||
|
|
}
|
||
|
|
|
||
|
|
// Mark as inactive in database
|
||
|
|
await sql`
|
||
|
|
UPDATE user_avatars
|
||
|
|
SET is_active = false, updated_at = CURRENT_TIMESTAMP
|
||
|
|
WHERE user_id = ${userId} AND is_active = true
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Error deleting old avatar:', error);
|
||
|
|
// Don't throw - this shouldn't prevent new uploads
|
||
|
|
}
|
||
|
|
}
|