deckhearth/pages/api/user/avatar.js

216 lines
6.6 KiB
JavaScript
Raw Normal View History

🖼️ Complete Avatar Upload System with Vercel Blob 📤 Avatar Upload API (/api/user/avatar): - File upload with multipart form data parsing - Comprehensive validation (file type, size limits) - Support for JPEG, PNG, GIF, WebP images up to 5MB - Automatic cleanup of old avatars before new uploads - Vercel Blob integration with public access - Database tracking in user_avatars table - Error handling for upload failures 🎨 Avatar Generation API (/api/user/avatar/generate): - Custom avatar generation using DiceBear API - Fire-themed color scheme (matching app branding) - Personalized based on user initials/username/email - SVG format for crisp display at any size - Automatic fallback if generation fails - Same cleanup and storage workflow as uploads 🗑️ Account Deletion API (/api/user/delete): - Complete user data cleanup including Vercel Blob files - Cascading deletion respecting foreign key constraints - Admin account protection (prevents self-deletion) - Comprehensive cleanup order: * User avatars from Vercel Blob storage * Deck cards, decks, collection cards, collections * User cards, avatar records, settings * Finally the user account itself - Detailed logging for audit trail - Graceful error handling with specific error messages 🔧 Technical Features: - Custom multipart form data parser for file uploads - Vercel Blob put/del operations with error handling - Unique filename generation with timestamps - Database transaction-like cleanup for deletions - File type validation and size limits - Proper CORS headers for all endpoints 🎯 Integration Ready: - Works seamlessly with existing profile page UI - Supports both upload and generate avatar buttons - Returns avatar URLs for immediate display - Database consistency with user profile system - Production-ready error handling and validation The avatar system is now fully functional with Vercel Blob! 📸✨
2025-07-26 22:39:42 -04:00
import { put, del } from '@vercel/blob';
import { sql } from '@vercel/postgres';
import { getUserFromRequest } from '../../../lib/permission-middleware';
feat(security): rate-limit search/upload/import + gate import routes (P0 #6) Closes P0 #6 (no rate limiting) from PARTIAL → RESOLVED. With this merge, all 8 P0 ship-blockers are RESOLVED. fix-auth-bypass Brief 4 shipped lib/rate-limit.js with a single 5/15min auth limiter wired into login + register; this brief extends the module to 5 named limiters (auth/search/upload/generate/import) and wires them into the remaining abusable surface. Per architect Decision 1 — Option A (gate all 3 import routes uniformly). The architect's investigation found a critical secondary bug: pages/admin/card-import.js's fetch sends NO Authorization header today. Adding getUserFromRequest to the import APIs without fixing the admin UI atomically would have returned 401 on every "Import Cards" click. Both edits ship in this single commit — API gating + admin UI Bearer fix — for atomic safety. Lorcana is dead in frontend today (only scripts/import-lorcana.js uses that path) but gated uniformly to future-proof per AGENTS.md § 1 status; a delete-dead-lorcana-import follow-up convoy is queued for later if we decide to drop Lorcana entirely. Per Decision 2 — hybrid named-limiter shape in lib/rate-limit.js. checkAuthRateLimit(req) signature + return shape preserved verbatim (don't break Brief 4's contract); 4 new named functions added (checkSearchRateLimit, checkUploadRateLimit, checkGenerateRateLimit, checkImportRateLimit). Map<className, Ratelimit> cache, per-class Redis prefix (tcgvault:auth, tcgvault:search, tcgvault:upload, tcgvault:generate, tcgvault:import) so each class has its own budget. Per Decision 3 — per-class limit values tuned with evidence: auth 5 / 15min IP-keyed (unchanged from Brief 4) search 60 / 1min IP-keyed (bumped from 30 — ShareModal has no debounce; 17-char email = 16 requests in <5s) upload 10 / 1hr user-keyed generate 5 / 1hr user-keyed (DiceBear is free, kept at 5) import 5 / 1hr user-keyed (admin-only; external APIs have their own limits) Per Decision 4 — two extractors. extractIpIdentifier (existing, unchanged) and extractUserIdentifier (new). The new one THROWS on null/undefined/empty/NaN userId to prevent silent fallback-to-IP (which would convert per-user limits into per-IP and lock out households). Architect's R-finding: places the gate AFTER the auth check on every per-user-keyed route, never before. Per Decision 5 — uniform 429 response shape verbatim matching login.js/register.js: Retry-After header + JSON { error: 'Too many attempts. Try again later.' }. Anti- fingerprinting (per-class messages would tell an attacker which classes have which limits). Per Decision 6 — no new per-route handler tests this convoy. Vitest 21/21 unchanged at merge. Verification: - npm run lint: 128 problems (baseline match) - npm run test:run: 21/21 vitest pass (no regression; auth-utils tests don't transitively load rate-limit per architect D6 evidence) - 5 named limiter exports verified via per-route grep counts - Admin UI sends Authorization: Bearer <token> from localStorage in the import fetch (matching pattern from other admin pages) - Brief 4's login.js + register.js byte-identical at HEAD - .cursor/rules/api-routes.mdc § Rate limiting extended with per-class table + gate-ordering rules No new dependencies (Brief 4's @upstash/ratelimit + @upstash/redis suffice). No workflow YAML changes. No AGENTS.md edits (doc- writer pass at convoy close handles Gotcha #12 update + § 6 testing update + ship-readiness Status summary 7/8 → 8/8). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 23:46:27 -04:00
import { checkUploadRateLimit } from '../../../lib/rate-limit.js';
🖼️ Complete Avatar Upload System with Vercel Blob 📤 Avatar Upload API (/api/user/avatar): - File upload with multipart form data parsing - Comprehensive validation (file type, size limits) - Support for JPEG, PNG, GIF, WebP images up to 5MB - Automatic cleanup of old avatars before new uploads - Vercel Blob integration with public access - Database tracking in user_avatars table - Error handling for upload failures 🎨 Avatar Generation API (/api/user/avatar/generate): - Custom avatar generation using DiceBear API - Fire-themed color scheme (matching app branding) - Personalized based on user initials/username/email - SVG format for crisp display at any size - Automatic fallback if generation fails - Same cleanup and storage workflow as uploads 🗑️ Account Deletion API (/api/user/delete): - Complete user data cleanup including Vercel Blob files - Cascading deletion respecting foreign key constraints - Admin account protection (prevents self-deletion) - Comprehensive cleanup order: * User avatars from Vercel Blob storage * Deck cards, decks, collection cards, collections * User cards, avatar records, settings * Finally the user account itself - Detailed logging for audit trail - Graceful error handling with specific error messages 🔧 Technical Features: - Custom multipart form data parser for file uploads - Vercel Blob put/del operations with error handling - Unique filename generation with timestamps - Database transaction-like cleanup for deletions - File type validation and size limits - Proper CORS headers for all endpoints 🎯 Integration Ready: - Works seamlessly with existing profile page UI - Supports both upload and generate avatar buttons - Returns avatar URLs for immediate display - Database consistency with user profile system - Production-ready error handling and validation The avatar system is now fully functional with Vercel Blob! 📸✨
2025-07-26 22:39:42 -04:00
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' });
}
feat(security): rate-limit search/upload/import + gate import routes (P0 #6) Closes P0 #6 (no rate limiting) from PARTIAL → RESOLVED. With this merge, all 8 P0 ship-blockers are RESOLVED. fix-auth-bypass Brief 4 shipped lib/rate-limit.js with a single 5/15min auth limiter wired into login + register; this brief extends the module to 5 named limiters (auth/search/upload/generate/import) and wires them into the remaining abusable surface. Per architect Decision 1 — Option A (gate all 3 import routes uniformly). The architect's investigation found a critical secondary bug: pages/admin/card-import.js's fetch sends NO Authorization header today. Adding getUserFromRequest to the import APIs without fixing the admin UI atomically would have returned 401 on every "Import Cards" click. Both edits ship in this single commit — API gating + admin UI Bearer fix — for atomic safety. Lorcana is dead in frontend today (only scripts/import-lorcana.js uses that path) but gated uniformly to future-proof per AGENTS.md § 1 status; a delete-dead-lorcana-import follow-up convoy is queued for later if we decide to drop Lorcana entirely. Per Decision 2 — hybrid named-limiter shape in lib/rate-limit.js. checkAuthRateLimit(req) signature + return shape preserved verbatim (don't break Brief 4's contract); 4 new named functions added (checkSearchRateLimit, checkUploadRateLimit, checkGenerateRateLimit, checkImportRateLimit). Map<className, Ratelimit> cache, per-class Redis prefix (tcgvault:auth, tcgvault:search, tcgvault:upload, tcgvault:generate, tcgvault:import) so each class has its own budget. Per Decision 3 — per-class limit values tuned with evidence: auth 5 / 15min IP-keyed (unchanged from Brief 4) search 60 / 1min IP-keyed (bumped from 30 — ShareModal has no debounce; 17-char email = 16 requests in <5s) upload 10 / 1hr user-keyed generate 5 / 1hr user-keyed (DiceBear is free, kept at 5) import 5 / 1hr user-keyed (admin-only; external APIs have their own limits) Per Decision 4 — two extractors. extractIpIdentifier (existing, unchanged) and extractUserIdentifier (new). The new one THROWS on null/undefined/empty/NaN userId to prevent silent fallback-to-IP (which would convert per-user limits into per-IP and lock out households). Architect's R-finding: places the gate AFTER the auth check on every per-user-keyed route, never before. Per Decision 5 — uniform 429 response shape verbatim matching login.js/register.js: Retry-After header + JSON { error: 'Too many attempts. Try again later.' }. Anti- fingerprinting (per-class messages would tell an attacker which classes have which limits). Per Decision 6 — no new per-route handler tests this convoy. Vitest 21/21 unchanged at merge. Verification: - npm run lint: 128 problems (baseline match) - npm run test:run: 21/21 vitest pass (no regression; auth-utils tests don't transitively load rate-limit per architect D6 evidence) - 5 named limiter exports verified via per-route grep counts - Admin UI sends Authorization: Bearer <token> from localStorage in the import fetch (matching pattern from other admin pages) - Brief 4's login.js + register.js byte-identical at HEAD - .cursor/rules/api-routes.mdc § Rate limiting extended with per-class table + gate-ordering rules No new dependencies (Brief 4's @upstash/ratelimit + @upstash/redis suffice). No workflow YAML changes. No AGENTS.md edits (doc- writer pass at convoy close handles Gotcha #12 update + § 6 testing update + ship-readiness Status summary 7/8 → 8/8). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 23:46:27 -04:00
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.' });
}
🖼️ Complete Avatar Upload System with Vercel Blob 📤 Avatar Upload API (/api/user/avatar): - File upload with multipart form data parsing - Comprehensive validation (file type, size limits) - Support for JPEG, PNG, GIF, WebP images up to 5MB - Automatic cleanup of old avatars before new uploads - Vercel Blob integration with public access - Database tracking in user_avatars table - Error handling for upload failures 🎨 Avatar Generation API (/api/user/avatar/generate): - Custom avatar generation using DiceBear API - Fire-themed color scheme (matching app branding) - Personalized based on user initials/username/email - SVG format for crisp display at any size - Automatic fallback if generation fails - Same cleanup and storage workflow as uploads 🗑️ Account Deletion API (/api/user/delete): - Complete user data cleanup including Vercel Blob files - Cascading deletion respecting foreign key constraints - Admin account protection (prevents self-deletion) - Comprehensive cleanup order: * User avatars from Vercel Blob storage * Deck cards, decks, collection cards, collections * User cards, avatar records, settings * Finally the user account itself - Detailed logging for audit trail - Graceful error handling with specific error messages 🔧 Technical Features: - Custom multipart form data parser for file uploads - Vercel Blob put/del operations with error handling - Unique filename generation with timestamps - Database transaction-like cleanup for deletions - File type validation and size limits - Proper CORS headers for all endpoints 🎯 Integration Ready: - Works seamlessly with existing profile page UI - Supports both upload and generate avatar buttons - Returns avatar URLs for immediate display - Database consistency with user profile system - Production-ready error handling and validation The avatar system is now fully functional with Vercel Blob! 📸✨
2025-07-26 22:39:42 -04:00
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
}
}