Initial project import

This commit is contained in:
drjones
2026-06-13 17:36:44 -07:00
commit ad2a18cc8d
18471 changed files with 4497570 additions and 0 deletions

BIN
server/database.sqlite Normal file

Binary file not shown.

83
server/db.js Normal file
View 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
View 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,
}));
}
}
}
}
});
});