diff --git a/.gitignore b/.gitignore index 0e04417..72b49e8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ __pycache__/ /.next/ /out/ +.env*.local diff --git a/package-lock.json b/package-lock.json index 38003e2..a949a1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@neondatabase/serverless": "^1.0.1", + "@vercel/blob": "^1.1.1", "@vercel/postgres": "^0.10.0", "bcryptjs": "^3.0.2", "dotenv": "^17.2.1", @@ -142,6 +143,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1641,6 +1651,22 @@ "win32" ] }, + "node_modules/@vercel/blob": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-1.1.1.tgz", + "integrity": "sha512-heiJGj2qt5qTv6yiShH9f6KRAoZGj+lz61GQ+lBRL4lhvUmKI9A51KYlQTnsUd9ymdFlKHBlvmPeG+yGz2Qsbg==", + "license": "Apache-2.0", + "dependencies": { + "async-retry": "^1.3.3", + "is-buffer": "^2.0.5", + "is-node-process": "^1.2.0", + "throttleit": "^2.1.0", + "undici": "^5.28.4" + }, + "engines": { + "node": ">=16.14" + } + }, "node_modules/@vercel/postgres": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@vercel/postgres/-/postgres-0.10.0.tgz", @@ -2010,6 +2036,15 @@ "node": ">= 0.4" } }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -4156,6 +4191,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/is-bun-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", @@ -4324,6 +4382,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5947,6 +6011,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -6776,6 +6849,18 @@ "node": ">=0.8" } }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -6995,6 +7080,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 5e2937a..96df425 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@neondatabase/serverless": "^1.0.1", + "@vercel/blob": "^1.1.1", "@vercel/postgres": "^0.10.0", "bcryptjs": "^3.0.2", "dotenv": "^17.2.1", diff --git a/pages/api/user/avatar.js b/pages/api/user/avatar.js new file mode 100644 index 0000000..f5d653c --- /dev/null +++ b/pages/api/user/avatar.js @@ -0,0 +1,220 @@ +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) { + // Set CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + // Handle preflight requests + if (req.method === 'OPTIONS') { + res.status(200).end(); + return; + } + + 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 + } +} \ No newline at end of file diff --git a/pages/api/user/avatar/generate.js b/pages/api/user/avatar/generate.js new file mode 100644 index 0000000..8d8b6b8 --- /dev/null +++ b/pages/api/user/avatar/generate.js @@ -0,0 +1,144 @@ +import { put } from '@vercel/blob'; +import { sql } from '@vercel/postgres'; +import { getUserFromRequest } from '../../../../lib/permission-middleware'; + +export default async function handler(req, res) { + // Set CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + // Handle preflight requests + if (req.method === 'OPTIONS') { + res.status(200).end(); + return; + } + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + // Get authenticated user + const user = await getUserFromRequest(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + // Get user information for avatar generation + const userResult = await sql` + SELECT email, first_name, last_name, username FROM users WHERE id = ${user.userId} + `; + + if (userResult.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + const userData = userResult.rows[0]; + + try { + // Delete old avatar if exists + await deleteOldAvatar(user.userId); + + // Generate avatar using a service (we'll use DiceBear Avatars as an example) + const avatarStyle = 'initials'; // You can change this to other styles like 'avataaars', 'bottts', etc. + const seed = userData.username || userData.email || `user-${user.userId}`; + const initials = getInitials(userData); + + // Create avatar URL with DiceBear API + const avatarUrl = `https://api.dicebear.com/7.x/${avatarStyle}/svg?seed=${encodeURIComponent(seed)}&chars=2&backgroundColor=d84315,ff5722,ff7043&textColor=ffffff&fontSize=40`; + + // Fetch the generated avatar + const avatarResponse = await fetch(avatarUrl); + if (!avatarResponse.ok) { + throw new Error('Failed to generate avatar'); + } + + const avatarBuffer = Buffer.from(await avatarResponse.arrayBuffer()); + + // Generate unique filename + const filename = `avatars/generated-${user.userId}-${Date.now()}.svg`; + + // Upload to Vercel Blob + const blob = await put(filename, avatarBuffer, { + access: 'public', + contentType: 'image/svg+xml', + }); + + // 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}, 'generated-avatar.svg', 'image/svg+xml', ${avatarBuffer.length}, ${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 generated successfully', + avatar_url: blob.url + }); + + } catch (generateError) { + console.error('Avatar generation error:', generateError); + res.status(500).json({ error: 'Failed to generate avatar' }); + } + + } catch (error) { + console.error('Avatar generation API error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +/** + * Get user initials for avatar generation + */ +function getInitials(userData) { + if (userData.first_name || userData.last_name) { + return `${userData.first_name?.charAt(0) || ''}${userData.last_name?.charAt(0) || ''}`.toUpperCase(); + } + if (userData.username) { + return userData.username.substring(0, 2).toUpperCase(); + } + return userData.email?.charAt(0).toUpperCase() || 'U'; +} + +/** + * 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 { + const { del } = await import('@vercel/blob'); + 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 + } +} \ No newline at end of file diff --git a/pages/api/user/delete.js b/pages/api/user/delete.js new file mode 100644 index 0000000..414992e --- /dev/null +++ b/pages/api/user/delete.js @@ -0,0 +1,115 @@ +import { del } from '@vercel/blob'; +import { sql } from '@vercel/postgres'; +import { getUserFromRequest } from '../../../lib/permission-middleware'; + +export default async function handler(req, res) { + // Set CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + // Handle preflight requests + if (req.method === 'OPTIONS') { + res.status(200).end(); + return; + } + + if (req.method !== 'DELETE') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + // Get authenticated user + const user = await getUserFromRequest(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + // Prevent admin users from deleting their own accounts + if (user.role === 'admin') { + return res.status(403).json({ + error: 'Admin accounts cannot be self-deleted. Please contact another administrator.' + }); + } + + try { + // Start transaction-like cleanup + console.log(`Starting account deletion for user ${user.userId}`); + + // 1. Delete user avatars from Vercel Blob + const avatarsResult = await sql` + SELECT file_path FROM user_avatars + WHERE user_id = ${user.userId} AND is_active = true + `; + + for (const avatar of avatarsResult.rows) { + try { + await del(avatar.file_path); + console.log(`Deleted avatar: ${avatar.file_path}`); + } catch (blobError) { + console.warn(`Failed to delete avatar blob: ${avatar.file_path}`, blobError); + // Continue with deletion even if blob cleanup fails + } + } + + // 2. Delete user data in correct order (respecting foreign key constraints) + + // Delete deck cards first + await sql`DELETE FROM deck_cards WHERE deck_id IN (SELECT id FROM decks WHERE user_id = ${user.userId})`; + console.log('Deleted deck cards'); + + // Delete decks + await sql`DELETE FROM decks WHERE user_id = ${user.userId}`; + console.log('Deleted decks'); + + // Delete collection cards + await sql`DELETE FROM collection_cards WHERE collection_id IN (SELECT id FROM collections WHERE user_id = ${user.userId})`; + console.log('Deleted collection cards'); + + // Delete collections + await sql`DELETE FROM collections WHERE user_id = ${user.userId}`; + console.log('Deleted collections'); + + // Delete user cards + await sql`DELETE FROM user_cards WHERE user_id = ${user.userId}`; + console.log('Deleted user cards'); + + // Delete user avatars records + await sql`DELETE FROM user_avatars WHERE user_id = ${user.userId}`; + console.log('Deleted user avatar records'); + + // Delete user settings + await sql`DELETE FROM user_settings WHERE user_id = ${user.userId}`; + console.log('Deleted user settings'); + + // Finally, delete the user account + const deleteResult = await sql`DELETE FROM users WHERE id = ${user.userId}`; + console.log('Deleted user account'); + + if (deleteResult.rowCount === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + console.log(`Successfully deleted account for user ${user.userId}`); + res.status(200).json({ + message: 'Account deleted successfully. All your data has been permanently removed.' + }); + + } catch (deleteError) { + console.error('Account deletion error:', deleteError); + + // Check if it's a foreign key constraint error + if (deleteError.code === '23503') { + return res.status(400).json({ + error: 'Cannot delete account due to data dependencies. Please contact support.' + }); + } + + res.status(500).json({ error: 'Failed to delete account. Please try again or contact support.' }); + } + + } catch (error) { + console.error('Account deletion API error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} \ No newline at end of file