From 83776bc6bec7abe1c982897b9351ff7330cd2387 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Wed, 23 Jul 2025 09:38:16 -0500 Subject: [PATCH] Add authentication system and admin API endpoints --- pages/api/admin/index.js | 324 +++++++++++++++++++++++++++++++++++++ pages/api/auth-utils.js | 57 +++++++ pages/api/auth/login.js | 64 ++++++++ pages/api/auth/register.js | 69 ++++++++ 4 files changed, 514 insertions(+) create mode 100644 pages/api/admin/index.js create mode 100644 pages/api/auth-utils.js create mode 100644 pages/api/auth/login.js create mode 100644 pages/api/auth/register.js diff --git a/pages/api/admin/index.js b/pages/api/admin/index.js new file mode 100644 index 0000000..229b691 --- /dev/null +++ b/pages/api/admin/index.js @@ -0,0 +1,324 @@ +import { sql } from '@vercel/postgres'; +import { verifyToken, isAdmin } from '../auth-utils.js'; + +// Rate limiting for external APIs +const rateLimiters = { + mtg: { lastCall: 0, minInterval: 50 }, // 50ms between calls + pokemon: { lastCall: 0, minInterval: 100 }, // 100ms between calls + lorcana: { lastCall: 0, minInterval: 100 } // 100ms between calls +}; + +async function waitForRateLimit(api) { + const now = Date.now(); + const limiter = rateLimiters[api]; + const timeSinceLastCall = now - limiter.lastCall; + + if (timeSinceLastCall < limiter.minInterval) { + await new Promise(resolve => + setTimeout(resolve, limiter.minInterval - timeSinceLastCall) + ); + } + + limiter.lastCall = Date.now(); +} + +// Load MTG cards from Scryfall +async function loadMTGCards() { + console.log('🃏 Loading MTG cards from Scryfall...'); + + try { + // Get total count first + const countResponse = await fetch('https://api.scryfall.com/cards/search?q=game:paper'); + const countData = await countResponse.json(); + const totalCards = countData.total_cards; + + console.log(`📊 Found ${totalCards} MTG cards to load`); + + let loadedCount = 0; + let page = 1; + + while (loadedCount < Math.min(totalCards, 1000)) { // Limit to 1000 for now + await waitForRateLimit('mtg'); + + const response = await fetch(`https://api.scryfall.com/cards/search?q=game:paper&page=${page}`); + const data = await response.json(); + + if (!data.data || data.data.length === 0) break; + + for (const card of data.data) { + try { + await sql.query(` + INSERT INTO cards ( + name, set_name, set_code, card_number, rarity, game, mana_cost, cmc, + card_type, colors, oracle_text, power, toughness, image_url, + stock_image_url, current_price, market_price, scryfall_id, verified + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ON CONFLICT (scryfall_id) DO NOTHING + `, [ + card.name, + card.set_name, + card.set, + card.collector_number, + card.rarity, + 'MTG', + card.mana_cost, + card.cmc, + card.type_line, + JSON.stringify(card.colors), + card.oracle_text, + card.power, + card.toughness, + card.image_uris?.normal || card.image_uris?.small, + card.image_uris?.small, + card.prices?.usd ? parseFloat(card.prices.usd) : null, + card.prices?.usd_foil ? parseFloat(card.prices.usd_foil) : null, + card.id, + true + ]); + + loadedCount++; + } catch (error) { + console.error(`❌ Error loading MTG card ${card.name}:`, error); + } + } + + page++; + console.log(`✅ Loaded ${loadedCount} MTG cards so far...`); + } + + console.log(`🎉 Successfully loaded ${loadedCount} MTG cards`); + return loadedCount; + } catch (error) { + console.error('❌ Error loading MTG cards:', error); + return 0; + } +} + +// Load Pokémon cards from Pokémon TCG API +async function loadPokemonCards() { + console.log('⚡ Loading Pokémon cards from Pokémon TCG API...'); + + try { + let loadedCount = 0; + let page = 1; + + while (loadedCount < 1000) { // Limit to 1000 for now + await waitForRateLimit('pokemon'); + + const response = await fetch(`https://api.pokemontcg.io/v2/cards?page=${page}&pageSize=250`); + const data = await response.json(); + + if (!data.data || data.data.length === 0) break; + + for (const card of data.data) { + try { + await sql.query(` + INSERT INTO cards ( + name, set_name, set_code, card_number, rarity, game, mana_cost, cmc, + card_type, colors, oracle_text, power, toughness, image_url, + stock_image_url, current_price, market_price, scryfall_id, verified + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ON CONFLICT (scryfall_id) DO NOTHING + `, [ + card.name, + card.set.name, + card.set.id, + card.number, + card.rarity, + 'POKEMON', + null, // No mana cost in Pokémon + null, // No CMC in Pokémon + card.supertype + (card.subtypes ? ' - ' + card.subtypes.join(', ') : ''), + JSON.stringify(card.types || []), + card.rules ? card.rules.join(' ') : null, + card.attacks?.[0]?.damage || null, + null, // No toughness in Pokémon + card.images?.large, + card.images?.small, + card.cardmarket?.prices?.averageSellPrice || null, + card.cardmarket?.prices?.lowPrice || null, + card.id, + true + ]); + + loadedCount++; + } catch (error) { + console.error(`❌ Error loading Pokémon card ${card.name}:`, error); + } + } + + page++; + console.log(`✅ Loaded ${loadedCount} Pokémon cards so far...`); + } + + console.log(`🎉 Successfully loaded ${loadedCount} Pokémon cards`); + return loadedCount; + } catch (error) { + console.error('❌ Error loading Pokémon cards:', error); + return 0; + } +} + +// Load Lorcana cards from Lorcana API +async function loadLorcanaCards() { + console.log('🏰 Loading Lorcana cards from Lorcana API...'); + + try { + let loadedCount = 0; + let page = 1; + + while (loadedCount < 1000) { // Limit to 1000 for now + await waitForRateLimit('lorcana'); + + const response = await fetch(`https://api.lorcana.com/cards?page=${page}&pageSize=250`); + const data = await response.json(); + + if (!data.data || data.data.length === 0) break; + + for (const card of data.data) { + try { + await sql.query(` + INSERT INTO cards ( + name, set_name, set_code, card_number, rarity, game, mana_cost, cmc, + card_type, colors, oracle_text, power, toughness, image_url, + stock_image_url, current_price, market_price, scryfall_id, verified + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ON CONFLICT (scryfall_id) DO NOTHING + `, [ + card.name, + card.set.name, + card.set.id, + card.number, + card.rarity, + 'LORCANA', + card.ink_cost ? card.ink_cost.toString() : null, + card.ink_cost || null, + card.type, + JSON.stringify(card.colors || []), + card.text || null, + null, // No power in Lorcana + null, // No toughness in Lorcana + card.image_url, + card.image_url, + card.price?.average || null, + card.price?.low || null, + card.id, + true + ]); + + loadedCount++; + } catch (error) { + console.error(`❌ Error loading Lorcana card ${card.name}:`, error); + } + } + + page++; + console.log(`✅ Loaded ${loadedCount} Lorcana cards so far...`); + } + + console.log(`🎉 Successfully loaded ${loadedCount} Lorcana cards`); + return loadedCount; + } catch (error) { + console.error('❌ Error loading Lorcana cards:', error); + return 0; + } +} + +export default async function handler(req, res) { + // Set CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, 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 !== 'GET' && req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + // Verify authentication + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + + if (!decoded) { + return res.status(401).json({ error: 'Invalid token' }); + } + + // Check admin permissions + const isUserAdmin = await isAdmin(decoded.userId); + if (!isUserAdmin) { + return res.status(403).json({ error: 'Admin access required' }); + } + + const { action, game } = req.query; + + if (req.method === 'GET') { + // Get system stats + if (action === 'stats') { + const stats = await sql` + SELECT + COUNT(*) as total_cards, + COUNT(CASE WHEN game = 'MTG' THEN 1 END) as mtg_cards, + COUNT(CASE WHEN game = 'POKEMON' THEN 1 END) as pokemon_cards, + COUNT(CASE WHEN game = 'LORCANA' THEN 1 END) as lorcana_cards + FROM cards + `; + + return res.status(200).json({ + success: true, + stats: stats.rows[0] + }); + } + + // Test endpoint + if (action === 'test') { + return res.status(200).json({ + success: true, + message: 'Admin API is working!', + user: decoded + }); + } + } + + if (req.method === 'POST') { + // Load cards based on game + if (action === 'load-cards') { + let loadedCount = 0; + + if (game === 'mtg' || !game) { + loadedCount += await loadMTGCards(); + } + + if (game === 'pokemon' || !game) { + loadedCount += await loadPokemonCards(); + } + + if (game === 'lorcana' || !game) { + loadedCount += await loadLorcanaCards(); + } + + return res.status(200).json({ + success: true, + message: `Successfully loaded ${loadedCount} cards`, + loadedCount + }); + } + } + + return res.status(400).json({ error: 'Invalid action' }); + + } catch (error) { + console.error('Admin API error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} \ No newline at end of file diff --git a/pages/api/auth-utils.js b/pages/api/auth-utils.js new file mode 100644 index 0000000..3685d0c --- /dev/null +++ b/pages/api/auth-utils.js @@ -0,0 +1,57 @@ +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcryptjs'; +import { sql } from '@vercel/postgres'; + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; + +export async function hashPassword(password) { + return await bcrypt.hash(password, 12); +} + +export async function verifyPassword(password, hashedPassword) { + return await bcrypt.compare(password, hashedPassword); +} + +export function generateToken(user) { + return jwt.sign( + { + userId: user.id, + email: user.email, + role: user.role + }, + JWT_SECRET, + { expiresIn: '7d' } + ); +} + +export function verifyToken(token) { + try { + return jwt.verify(token, JWT_SECRET); + } catch (error) { + return null; + } +} + +export async function isAdmin(userId) { + try { + const result = await sql` + SELECT role FROM users WHERE id = ${userId} + `; + return result.rows[0]?.role === 'admin'; + } catch (error) { + console.error('Error checking admin status:', error); + return false; + } +} + +export async function getUserById(userId) { + try { + const result = await sql` + SELECT id, email, role, created_at FROM users WHERE id = ${userId} + `; + return result.rows[0]; + } catch (error) { + console.error('Error getting user:', error); + return null; + } +} \ No newline at end of file diff --git a/pages/api/auth/login.js b/pages/api/auth/login.js new file mode 100644 index 0000000..41942a4 --- /dev/null +++ b/pages/api/auth/login.js @@ -0,0 +1,64 @@ +import { sql } from '@vercel/postgres'; +import { verifyPassword, generateToken } from '../auth-utils.js'; + +export default async function handler(req, res) { + // Set CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, 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 !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ error: 'Email and password are required' }); + } + + // Get user from database + const result = await sql` + SELECT id, email, password, role FROM users WHERE email = ${email} + `; + + if (result.rows.length === 0) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + const user = result.rows[0]; + + // Verify password + const isValid = await verifyPassword(password, user.password); + if (!isValid) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + // Generate token + const token = generateToken({ + id: user.id, + email: user.email, + role: user.role + }); + + // Return user data (without password) and token + const { password: _, ...userWithoutPassword } = user; + + res.status(200).json({ + success: true, + user: userWithoutPassword, + token + }); + + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} \ No newline at end of file diff --git a/pages/api/auth/register.js b/pages/api/auth/register.js new file mode 100644 index 0000000..b7b45d5 --- /dev/null +++ b/pages/api/auth/register.js @@ -0,0 +1,69 @@ +import { sql } from '@vercel/postgres'; +import { hashPassword, generateToken } from '../auth-utils.js'; + +export default async function handler(req, res) { + // Set CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, 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 !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ error: 'Email and password are required' }); + } + + if (password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + + // Check if user already exists + const existingUser = await sql` + SELECT id FROM users WHERE email = ${email} + `; + + if (existingUser.rows.length > 0) { + return res.status(409).json({ error: 'User already exists' }); + } + + // Hash password + const hashedPassword = await hashPassword(password); + + // Create user + const result = await sql` + INSERT INTO users (email, password, role) + VALUES (${email}, ${hashedPassword}, 'user') + RETURNING id, email, role, created_at + `; + + const user = result.rows[0]; + + // Generate token + const token = generateToken({ + id: user.id, + email: user.email, + role: user.role + }); + + res.status(201).json({ + success: true, + user, + token + }); + + } catch (error) { + console.error('Registration error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} \ No newline at end of file