feat: search, presets, dependency warnings, port conflicts, URL state, drag-drop analyser, diff view, auth + community gallery API (PostgreSQL), professional UI overhaul
This commit is contained in:
288
api/server.js
288
api/server.js
@@ -1,90 +1,237 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
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_FILE = join(__dirname, 'data', 'popularity.json');
|
||||
const DOWNLOADS_LOG = join(__dirname, 'data', 'downloads.json');
|
||||
const DATA_DIR = join(__dirname, 'data');
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Ensure data dir exists
|
||||
import { mkdirSync } from 'fs';
|
||||
mkdirSync(join(__dirname, 'data'), { recursive: true });
|
||||
const { Pool } = pg;
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
// Load or init popularity data
|
||||
function loadPopularity() {
|
||||
if (existsSync(DATA_FILE)) {
|
||||
return JSON.parse(readFileSync(DATA_FILE, 'utf8'));
|
||||
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' });
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function savePopularity(data) {
|
||||
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function loadDownloads() {
|
||||
if (existsSync(DOWNLOADS_LOG)) {
|
||||
return JSON.parse(readFileSync(DOWNLOADS_LOG, 'utf8'));
|
||||
function optionalAuth(req, res, next) {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
if (token) {
|
||||
try { req.user = jwt.verify(token, JWT_SECRET); } catch (e) {}
|
||||
}
|
||||
return { total: 0, history: [] };
|
||||
next();
|
||||
}
|
||||
|
||||
function saveDownloads(data) {
|
||||
writeFileSync(DOWNLOADS_LOG, JSON.stringify(data, null, 2));
|
||||
}
|
||||
// ============ 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)); }
|
||||
|
||||
// POST /api/analytics/download — record a bundle download
|
||||
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' });
|
||||
}
|
||||
|
||||
// Update popularity counts
|
||||
const popularity = loadPopularity();
|
||||
for (const appId of apps) {
|
||||
popularity[appId] = (popularity[appId] || 0) + 1;
|
||||
}
|
||||
savePopularity(popularity);
|
||||
|
||||
// Log the download
|
||||
const downloads = loadDownloads();
|
||||
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,
|
||||
});
|
||||
// Keep last 10,000 entries to avoid unbounded growth
|
||||
if (downloads.history.length > 10000) {
|
||||
downloads.history = downloads.history.slice(-10000);
|
||||
}
|
||||
saveDownloads(downloads);
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
// GET /api/analytics/popularity — get app download counts
|
||||
app.get('/api/analytics/popularity', (req, res) => {
|
||||
const popularity = loadPopularity();
|
||||
res.json(popularity);
|
||||
});
|
||||
app.get('/api/analytics/popularity', (req, res) => { res.json(loadJson(POPULARITY_FILE, {})); });
|
||||
|
||||
// GET /api/analytics/stats — overview stats
|
||||
app.get('/api/analytics/stats', (req, res) => {
|
||||
const popularity = loadPopularity();
|
||||
const downloads = loadDownloads();
|
||||
|
||||
// Top apps
|
||||
const sorted = Object.entries(popularity)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
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,
|
||||
@@ -93,23 +240,6 @@ app.get('/api/analytics/stats', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/analytics/app/:id — stats for a specific app
|
||||
app.get('/api/analytics/app/:id', (req, res) => {
|
||||
const popularity = loadPopularity();
|
||||
const count = popularity[req.params.id] || 0;
|
||||
const downloads = loadDownloads();
|
||||
const total = downloads.total || 1;
|
||||
const percentage = ((count / total) * 100).toFixed(1);
|
||||
|
||||
res.json({
|
||||
id: req.params.id,
|
||||
download_count: count,
|
||||
percentage: `${percentage}%`,
|
||||
total_bundles: total,
|
||||
});
|
||||
});
|
||||
|
||||
// ============ START ============
|
||||
const PORT = process.env.PORT || 3052;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`StackPilot Analytics API running on port ${PORT}`);
|
||||
});
|
||||
app.listen(PORT, () => { console.log(`StackPilot API running on port ${PORT}`); });
|
||||
|
||||
Reference in New Issue
Block a user