470 lines
15 KiB
JavaScript
470 lines
15 KiB
JavaScript
import express from 'express';
|
|
import cors from 'cors';
|
|
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(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
|
|
|
|
wss.on('connection', (ws) => {
|
|
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,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|