Initial project import
This commit is contained in:
BIN
server/database.sqlite
Normal file
BIN
server/database.sqlite
Normal file
Binary file not shown.
83
server/db.js
Normal file
83
server/db.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const dbPath = join(__dirname, 'database.sqlite');
|
||||
|
||||
// Ensure db directory exists
|
||||
if (!fs.existsSync(__dirname)) {
|
||||
fs.mkdirSync(__dirname, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
// Helper to run database queries with Promises
|
||||
export const query = {
|
||||
run(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function (err) {
|
||||
if (err) reject(err);
|
||||
else resolve({ lastID: this.lastID, changes: this.changes });
|
||||
});
|
||||
});
|
||||
},
|
||||
get(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
},
|
||||
all(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize schema
|
||||
export async function initDb() {
|
||||
// Users table
|
||||
await query.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
purchased_slots INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Projects table
|
||||
await query.run(`
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
parts TEXT NOT NULL,
|
||||
saved_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id, name)
|
||||
)
|
||||
`);
|
||||
|
||||
// Purchases table
|
||||
await query.run(`
|
||||
CREATE TABLE IF NOT EXISTS purchases (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('Database initialized successfully at:', dbPath);
|
||||
}
|
||||
469
server/index.js
Normal file
469
server/index.js
Normal file
@@ -0,0 +1,469 @@
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user