import express from 'express'; import cors from 'cors'; import pg from 'pg'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { config } from 'dotenv'; config(); const __dirname = dirname(fileURLToPath(import.meta.url)); const DATA_DIR = join(__dirname, 'data'); mkdirSync(DATA_DIR, { recursive: true }); const app = express(); app.use(cors()); app.use(express.json()); const { Pool } = pg; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const JWT_SECRET = process.env.JWT_SECRET || 'stackpilot-dev-secret'; // ============ AUTH MIDDLEWARE ============ function authMiddleware(req, res, next) { const token = req.headers.authorization?.replace('Bearer ', ''); if (!token) return res.status(401).json({ error: 'Authentication required' }); try { req.user = jwt.verify(token, JWT_SECRET); next(); } catch (e) { res.status(401).json({ error: 'Invalid token' }); } } function optionalAuth(req, res, next) { const token = req.headers.authorization?.replace('Bearer ', ''); if (token) { try { req.user = jwt.verify(token, JWT_SECRET); } catch (e) {} } next(); } // ============ AUTH ROUTES ============ app.post('/api/auth/register', async (req, res) => { const { username, email, password, display_name } = req.body; if (!username || !email || !password) { return res.status(400).json({ error: 'username, email, and password required' }); } if (password.length < 6) { return res.status(400).json({ error: 'Password must be at least 6 characters' }); } try { const hash = await bcrypt.hash(password, 10); const result = await pool.query( 'INSERT INTO users (username, email, password_hash, display_name) VALUES ($1, $2, $3, $4) RETURNING id, username, email, display_name, created_at', [username, email, hash, display_name || username] ); const user = result.rows[0]; const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '30d' }); res.json({ user, token }); } catch (e) { if (e.code === '23505') { return res.status(409).json({ error: 'Username or email already exists' }); } res.status(500).json({ error: 'Registration failed' }); } }); app.post('/api/auth/login', async (req, res) => { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ error: 'email and password required' }); } try { const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]); const user = result.rows[0]; if (!user || !(await bcrypt.compare(password, user.password_hash))) { return res.status(401).json({ error: 'Invalid email or password' }); } const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '30d' }); res.json({ user: { id: user.id, username: user.username, email: user.email, display_name: user.display_name }, token }); } catch (e) { res.status(500).json({ error: 'Login failed' }); } }); app.get('/api/auth/me', authMiddleware, async (req, res) => { const result = await pool.query('SELECT id, username, email, display_name, avatar_url, created_at FROM users WHERE id = $1', [req.user.id]); if (result.rows.length === 0) return res.status(404).json({ error: 'User not found' }); res.json(result.rows[0]); }); // ============ COMMUNITY STACKS (GALLERY) ============ app.get('/api/stacks', optionalAuth, async (req, res) => { const { sort = 'stars', search, tag, limit = 20, offset = 0 } = req.query; let query = 'SELECT s.*, u.username, u.display_name FROM stacks s JOIN users u ON s.user_id = u.id WHERE s.is_public = true'; const params = []; let paramIdx = 1; if (search) { query += ` AND (s.name ILIKE $${paramIdx} OR s.description ILIKE $${paramIdx})`; params.push(`%${search}%`); paramIdx++; } if (tag) { query += ` AND $${paramIdx} = ANY(s.tags)`; params.push(tag); paramIdx++; } const orderMap = { stars: 'stars DESC', downloads: 'downloads DESC', recent: 'created_at DESC' }; query += ` ORDER BY ${orderMap[sort] || 'stars DESC'}`; query += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`; params.push(limit, offset); const result = await pool.query(query, params); // If user is logged in, mark which ones they've starred if (req.user) { const starResult = await pool.query('SELECT stack_id FROM stars WHERE user_id = $1', [req.user.id]); const starred = new Set(starResult.rows.map(r => r.stack_id)); result.rows.forEach(s => { s.starred_by_me = starred.has(s.id); }); } res.json(result.rows); }); app.get('/api/stacks/:id', optionalAuth, async (req, res) => { const result = await pool.query( 'SELECT s.*, u.username, u.display_name FROM stacks s JOIN users u ON s.user_id = u.id WHERE s.id = $1', [req.params.id] ); if (result.rows.length === 0) return res.status(404).json({ error: 'Stack not found' }); const stack = result.rows[0]; if (!stack.is_public && (!req.user || req.user.id !== stack.user_id)) { return res.status(404).json({ error: 'Stack not found' }); } res.json(stack); }); app.post('/api/stacks', authMiddleware, async (req, res) => { const { name, description, apps, config: stackConfig, tags, is_public = true } = req.body; if (!name || !apps || !Array.isArray(apps) || apps.length === 0) { return res.status(400).json({ error: 'name and apps array required' }); } const result = await pool.query( 'INSERT INTO stacks (user_id, name, description, apps, config, tags, is_public) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *', [req.user.id, name, description || '', apps, stackConfig || {}, tags || [], is_public] ); res.json(result.rows[0]); }); app.put('/api/stacks/:id', authMiddleware, async (req, res) => { const { name, description, apps, config: stackConfig, tags, is_public } = req.body; const existing = await pool.query('SELECT * FROM stacks WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); if (existing.rows.length === 0) return res.status(404).json({ error: 'Stack not found or not yours' }); const result = await pool.query( 'UPDATE stacks SET name = COALESCE($1, name), description = COALESCE($2, description), apps = COALESCE($3, apps), config = COALESCE($4, config), tags = COALESCE($5, tags), is_public = COALESCE($6, is_public), updated_at = NOW() WHERE id = $7 RETURNING *', [name, description, apps, stackConfig, tags, is_public, req.params.id] ); res.json(result.rows[0]); }); app.delete('/api/stacks/:id', authMiddleware, async (req, res) => { await pool.query('DELETE FROM stars WHERE stack_id = $1', [req.params.id]); const result = await pool.query('DELETE FROM stacks WHERE id = $1 AND user_id = $2 RETURNING id', [req.params.id, req.user.id]); if (result.rows.length === 0) return res.status(404).json({ error: 'Stack not found or not yours' }); res.json({ ok: true }); }); // Star/unstar app.post('/api/stacks/:id/star', authMiddleware, async (req, res) => { try { await pool.query('INSERT INTO stars (user_id, stack_id) VALUES ($1, $2)', [req.user.id, req.params.id]); await pool.query('UPDATE stacks SET stars = stars + 1 WHERE id = $1', [req.params.id]); res.json({ starred: true }); } catch (e) { if (e.code === '23505') { // Already starred — unstar await pool.query('DELETE FROM stars WHERE user_id = $1 AND stack_id = $2', [req.user.id, req.params.id]); await pool.query('UPDATE stacks SET stars = GREATEST(stars - 1, 0) WHERE id = $1', [req.params.id]); res.json({ starred: false }); } else { res.status(500).json({ error: 'Failed to star' }); } } }); // Track stack download app.post('/api/stacks/:id/download', async (req, res) => { await pool.query('UPDATE stacks SET downloads = downloads + 1 WHERE id = $1', [req.params.id]); res.json({ ok: true }); }); // My stacks app.get('/api/my/stacks', authMiddleware, async (req, res) => { const result = await pool.query('SELECT * FROM stacks WHERE user_id = $1 ORDER BY updated_at DESC', [req.user.id]); res.json(result.rows); }); // ============ ANALYTICS (from before) ============ const POPULARITY_FILE = join(DATA_DIR, 'popularity.json'); const DOWNLOADS_FILE = join(DATA_DIR, 'downloads.json'); function loadJson(path, fallback) { return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : fallback; } function saveJson(path, data) { writeFileSync(path, JSON.stringify(data, null, 2)); } app.post('/api/analytics/download', (req, res) => { const { apps, timestamp } = req.body; if (!apps || !Array.isArray(apps)) return res.status(400).json({ error: 'apps array required' }); const popularity = loadJson(POPULARITY_FILE, {}); for (const appId of apps) { popularity[appId] = (popularity[appId] || 0) + 1; } saveJson(POPULARITY_FILE, popularity); const downloads = loadJson(DOWNLOADS_FILE, { total: 0, history: [] }); downloads.total += 1; downloads.history.push({ timestamp: timestamp || new Date().toISOString(), apps, count: apps.length }); if (downloads.history.length > 10000) downloads.history = downloads.history.slice(-10000); saveJson(DOWNLOADS_FILE, downloads); res.json({ ok: true, total_downloads: downloads.total }); }); app.get('/api/analytics/popularity', (req, res) => { res.json(loadJson(POPULARITY_FILE, {})); }); app.get('/api/analytics/stats', (req, res) => { const popularity = loadJson(POPULARITY_FILE, {}); const downloads = loadJson(DOWNLOADS_FILE, { total: 0, history: [] }); const sorted = Object.entries(popularity).sort((a, b) => b[1] - a[1]); res.json({ total_downloads: downloads.total, unique_apps_selected: sorted.length, top_20: sorted.slice(0, 20).map(([id, count]) => ({ id, count })), recent: downloads.history.slice(-10).reverse(), }); }); // ============ START ============ const PORT = process.env.PORT || 3052; app.listen(PORT, () => { console.log(`StackPilot API running on port ${PORT}`); });