import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import { WebSocketServer } from 'ws'; import jwt from 'jsonwebtoken'; import bcrypt from 'bcryptjs'; import Stripe from 'stripe'; import dotenv from 'dotenv'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import fs from 'fs'; import { initDb, query } from './db.js'; dotenv.config(); const app = express(); const PORT = process.env.PORT || 3000; const JWT_SECRET = process.env.JWT_SECRET || 'hydro_secret_jwt_key_123456'; const stripe = new Stripe('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'); // Initialize Database initDb().catch((err) => { console.error('Failed to initialize database:', err); }); // Middleware configuration app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false, })); app.use(cors()); // Stripe Webhook needs raw body, configure other routes to use express.json() app.use((req, res, next) => { if (req.originalUrl === '/api/stripe/webhook') { next(); } else { express.json({ limit: '10mb' })(req, res, next); } }); // Authentication Middleware function authenticateToken(req, res, next) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Access token required' }); jwt.verify(token, JWT_SECRET, (err, user) => { if (err) return res.status(403).json({ error: 'Invalid or expired token' }); req.user = user; next(); }); } // Simple in-memory rate limiter const rateLimitMap = new Map(); const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute const RATE_LIMIT_MAX = 10; // max attempts per window function rateLimit(key, max = RATE_LIMIT_MAX) { const now = Date.now(); const entry = rateLimitMap.get(key); if (!entry || now - entry.start > RATE_LIMIT_WINDOW_MS) { rateLimitMap.set(key, { start: now, count: 1 }); return false; // not limited } entry.count++; if (entry.count > max) return true; // limited return false; } // Clean up stale entries every 5 minutes setInterval(() => { const now = Date.now(); for (const [key, entry] of rateLimitMap) { if (now - entry.start > RATE_LIMIT_WINDOW_MS * 2) rateLimitMap.delete(key); } }, 5 * 60 * 1000); // --- Auth Routes --- app.post('/api/auth/signup', async (req, res) => { try { if (rateLimit('signup:' + req.ip, 5)) return res.status(429).json({ error: 'Too many signup attempts. Try again in a minute.' }); const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ error: 'Email and password are required' }); } const passwordHash = await bcrypt.hash(password, 10); try { const result = await query.run( 'INSERT INTO users (email, password_hash, purchased_slots) VALUES (?, ?, 0)', [email, passwordHash] ); const userId = result.lastID; const token = jwt.sign({ id: userId, email }, JWT_SECRET, { expiresIn: '7d' }); res.status(201).json({ token, user: { id: userId, email, purchased_slots: 0, total_slots: 0 } }); } catch (dbErr) { if (dbErr.message.includes('UNIQUE constraint failed')) { return res.status(400).json({ error: 'Email already registered' }); } throw dbErr; } } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to create user' }); } }); app.post('/api/auth/login', async (req, res) => { try { if (rateLimit('login:' + req.ip, 10)) return res.status(429).json({ error: 'Too many login attempts. Try again in a minute.' }); const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ error: 'Email and password are required' }); } const user = await query.get('SELECT * FROM users WHERE email = ?', [email]); if (!user) { return res.status(400).json({ error: 'Invalid email or password' }); } const validPassword = await bcrypt.compare(password, user.password_hash); if (!validPassword) { return res.status(400).json({ error: 'Invalid email or password' }); } const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '7d' }); // Get project count const projCountResult = await query.get( 'SELECT COUNT(*) as count FROM projects WHERE user_id = ?', [user.id] ); res.json({ token, user: { id: user.id, email: user.email, purchased_slots: user.purchased_slots, total_slots: user.purchased_slots > 0 ? 10 : 0, project_count: projCountResult?.count || 0 } }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to log in' }); } }); app.get('/api/auth/me', authenticateToken, async (req, res) => { try { const user = await query.get('SELECT id, email, purchased_slots FROM users WHERE id = ?', [req.user.id]); if (!user) { return res.status(404).json({ error: 'User not found' }); } const projCountResult = await query.get( 'SELECT COUNT(*) as count FROM projects WHERE user_id = ?', [user.id] ); res.json({ user: { id: user.id, email: user.email, purchased_slots: user.purchased_slots, total_slots: user.purchased_slots > 0 ? 10 : 0, project_count: projCountResult?.count || 0 } }); } catch (err) { console.error(err); res.status(500).json({ error: 'Server error' }); } }); // --- Project Routes --- app.get('/api/projects', authenticateToken, async (req, res) => { try { const rows = await query.all('SELECT name, saved_at, parts FROM projects WHERE user_id = ? ORDER BY saved_at DESC', [req.user.id]); const projects = rows.map(r => ({ name: r.name, savedAt: r.saved_at, parts: JSON.parse(r.parts) })); res.json(projects); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to fetch projects' }); } }); app.post('/api/projects', authenticateToken, async (req, res) => { try { const { name, parts } = req.body; if (!name || !parts) { return res.status(400).json({ error: 'Name and parts are required' }); } const partsStr = JSON.stringify(parts); const userId = req.user.id; // Check if project already exists const existing = await query.get( 'SELECT id FROM projects WHERE user_id = ? AND name = ?', [userId, name] ); if (existing) { // Overwrite/update is always allowed await query.run( 'UPDATE projects SET parts = ?, saved_at = CURRENT_TIMESTAMP WHERE user_id = ? AND name = ?', [partsStr, userId, name] ); return res.json({ success: true, message: 'Project updated' }); } // Creating a new project. Check user slots. const user = await query.get('SELECT purchased_slots FROM users WHERE id = ?', [userId]); if (!user || user.purchased_slots === 0) { return res.status(402).json({ error: 'Upgrade required', message: 'Free profiles do not support saving projects to the cloud. Please upgrade to Premium to enable cloud project storage.', limitReached: true }); } const projCountResult = await query.get( 'SELECT COUNT(*) as count FROM projects WHERE user_id = ?', [userId] ); const allowed = 10; const currentCount = projCountResult?.count || 0; if (currentCount >= allowed) { return res.status(402).json({ error: 'Limit reached', message: `You have reached the maximum allowed projects (${allowed}). Please delete an existing project or contact support.`, limitReached: true }); } // Insert new project const id = Math.random().toString(36).substring(2) + Date.now().toString(36); await query.run( 'INSERT INTO projects (id, user_id, name, parts) VALUES (?, ?, ?, ?)', [id, userId, name, partsStr] ); res.status(212).json({ success: true, message: 'Project created' }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to save project' }); } }); app.delete('/api/projects/:name', authenticateToken, async (req, res) => { try { const { name } = req.params; const result = await query.run( 'DELETE FROM projects WHERE user_id = ? AND name = ?', [req.user.id, name] ); if (result.changes === 0) { return res.status(404).json({ error: 'Project not found' }); } res.json({ success: true, message: 'Project deleted' }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to delete project' }); } }); // --- Stripe Routes --- app.post('/api/payments/create-checkout-session', authenticateToken, async (req, res) => { try { const userId = req.user.id; const userEmail = req.user.email; // Use current request protocol and host for redirect URLs const referer = req.headers.referer || 'http://localhost:5173/'; const urlObj = new URL(referer); const origin = urlObj.origin; const session = await stripe.checkout.sessions.create({ payment_method_types: ['card'], customer_email: userEmail, client_reference_id: String(userId), line_items: [ { price_data: { currency: 'usd', product_data: { name: 'Premium Upgrade', description: 'Unlock browser auto-saves and 10 cloud build spaces permanently', }, unit_amount: 500, // $5.00 }, quantity: 1, }, ], mode: 'payment', success_url: `${origin}/?checkout_success=true`, cancel_url: `${origin}/?checkout_cancel=true`, }); res.json({ url: session.url }); } catch (err) { console.error('Stripe Checkout Error:', err); res.status(500).json({ error: 'Failed to create checkout session' }); } }); // Stripe webhook handler app.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.headers['stripe-signature']; let event; try { const webhookSecret = 'whsec_0tfSSS0x95tjdgPiyBe91q76ORpJ9Vyx'; event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret); } catch (err) { console.error('Webhook signature verification failed:', err.message); return res.status(400).send(`Webhook Error: ${err.message}`); } // Handle the event if (event.type === 'checkout.session.completed' || event.type === 'checkout.session.async_payment_succeeded') { const session = event.data.object; const userId = session.client_reference_id; const amountTotal = session.amount_total; const stripeSessionId = session.id; if (userId) { try { await query.run('BEGIN TRANSACTION'); // Increment slot count (set to 10 to activate premium tier) await query.run( 'UPDATE users SET purchased_slots = 10 WHERE id = ?', [userId] ); // Record purchase history await query.run( 'INSERT INTO purchases (id, user_id, amount, status) VALUES (?, ?, ?, ?)', [stripeSessionId, userId, amountTotal, 'completed'] ); await query.run('COMMIT'); console.log(`Successfully upgraded user ID ${userId} to Premium (Stripe Session: ${stripeSessionId})`); } catch (dbErr) { try { await query.run('ROLLBACK'); } catch (rollbackErr) { console.error('Failed to rollback transaction:', rollbackErr); } console.error('Failed to update purchased slots in database:', dbErr); return res.status(500).send('Database update failed'); } } else { console.warn('No client_reference_id found in completed session:', session.id); } } else if (event.type === 'checkout.session.async_payment_failed') { console.warn(`Payment failed for checkout session: ${event.data.object.id}`); } res.json({ received: true }); }); // Serve static assets in production const __dirname = dirname(fileURLToPath(import.meta.url)); const distPath = join(__dirname, '../dist'); if (fs.existsSync(distPath)) { app.use(express.static(distPath)); app.get(/.*/, (req, res) => { res.sendFile(join(distPath, 'index.html')); }); } const server = app.listen(PORT, () => { console.log(`Backend server running on http://localhost:${PORT}`); }); const wss = new WebSocketServer({ server }); const rooms = new Map(); // projectRoom -> Set of ws clients // Heartbeat to prevent memory leaks from dead connections const interval = setInterval(() => { wss.clients.forEach((ws) => { if (ws.isAlive === false) return ws.terminate(); ws.isAlive = false; ws.ping(); }); }, 30000); wss.on('close', () => { clearInterval(interval); }); wss.on('connection', (ws) => { ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); let currentRoom = null; let userId = null; ws.on('message', (message) => { try { const data = JSON.parse(message); if (data.type === 'join') { currentRoom = data.projectRoom; userId = data.userId; ws.userId = userId; ws.userName = data.userName; ws.color = data.color || '#38bdf8'; ws.activeRoomId = data.activeRoomId; if (!rooms.has(currentRoom)) { rooms.set(currentRoom, new Set()); } rooms.get(currentRoom).add(ws); console.log(`User ${ws.userName} joined room ${currentRoom}`); } else if (data.type === 'mutation') { if (currentRoom && rooms.has(currentRoom)) { // Broadcast parts mutation to all other clients in the same project room for (const client of rooms.get(currentRoom)) { if (client !== ws && client.readyState === 1) { // 1 is WebSocket.OPEN client.send(JSON.stringify({ type: 'mutation', parts: data.parts, })); } } } } else if (data.type === 'cursor') { ws.activeRoomId = data.activeRoomId; if (currentRoom && rooms.has(currentRoom)) { // Broadcast cursor position to all other clients in the same project room for (const client of rooms.get(currentRoom)) { if (client !== ws && client.readyState === 1) { client.send(JSON.stringify({ type: 'cursor', userId: userId, userName: ws.userName, color: ws.color, position: data.position, activeRoomId: data.activeRoomId, })); } } } } } catch (err) { console.error('WebSocket message error:', err); } }); ws.on('close', () => { if (currentRoom && rooms.has(currentRoom)) { rooms.get(currentRoom).delete(ws); if (rooms.get(currentRoom).size === 0) { rooms.delete(currentRoom); } else { // Broadcast leave to other clients so they remove the cursor for (const client of rooms.get(currentRoom)) { if (client.readyState === 1) { client.send(JSON.stringify({ type: 'leave', userId: userId, })); } } } } }); });