diff --git a/api/.env b/api/.env
new file mode 100644
index 0000000..eec03f6
--- /dev/null
+++ b/api/.env
@@ -0,0 +1,3 @@
+DATABASE_URL=postgresql://fitcoach:localdev@192.168.4.20:5432/stackpilot
+JWT_SECRET=stackpilot-jwt-secret-change-in-production
+PORT=3052
diff --git a/api/package-lock.json b/api/package-lock.json
index c536b11..c3ffc9b 100644
--- a/api/package-lock.json
+++ b/api/package-lock.json
@@ -8,8 +8,13 @@
"name": "stackpilot-api",
"version": "1.0.0",
"dependencies": {
+ "bcryptjs": "^2.4.3",
"cors": "2.8.5",
- "express": "4.21.2"
+ "dotenv": "^16.4.7",
+ "express": "4.21.2",
+ "jsonwebtoken": "^9.0.2",
+ "pg": "^8.13.1",
+ "sql.js": "^1.11.0"
}
},
"node_modules/accepts": {
@@ -31,6 +36,12 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
+ "node_modules/bcryptjs": {
+ "version": "2.4.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
+ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
+ "license": "MIT"
+ },
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -55,6 +66,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -170,6 +187,18 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/dotenv": {
+ "version": "16.4.7",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
+ "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -184,6 +213,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -451,6 +489,97 @@
"node": ">= 0.10"
}
},
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
+ "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^3.2.2",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
+ "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
+ "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^1.4.2",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -583,6 +712,131 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
+ "node_modules/pg": {
+ "version": "8.13.1",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz",
+ "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.7.0",
+ "pg-pool": "^3.7.0",
+ "pg-protocol": "^1.7.0",
+ "pg-types": "^2.1.0",
+ "pgpass": "1.x"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.1.1"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
+ "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.6.tgz",
+ "integrity": "sha512-lqIfH7bdgsxHAY/ZnUOwm+aCFKrsHBDhSFuk9O0B9uCqJAIkrKTo/+LQqLPLUS4e04+jCmQVikxE3QipH5chPw==",
+ "license": "MIT"
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -661,6 +915,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
@@ -793,6 +1059,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/sql.js": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.11.0.tgz",
+ "integrity": "sha512-GsLUDU3vhOo14Pd5ME0y2te49JQyby6HuoCuadevEV+CGgTUjmYRrm7B7lhRyzOgrmcWmspUfyjNb6sOAEqdsA==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -850,6 +1122,15 @@
"engines": {
"node": ">= 0.8"
}
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
diff --git a/api/package.json b/api/package.json
index 89122fe..7fc37a3 100644
--- a/api/package.json
+++ b/api/package.json
@@ -6,7 +6,12 @@
"start": "node server.js"
},
"dependencies": {
+ "bcryptjs": "^2.4.3",
+ "cors": "2.8.5",
+ "dotenv": "^16.4.7",
"express": "4.21.2",
- "cors": "2.8.5"
+ "jsonwebtoken": "^9.0.2",
+ "pg": "^8.13.1",
+ "sql.js": "^1.11.0"
}
}
diff --git a/api/server.js b/api/server.js
index 35bed9e..0ac385d 100644
--- a/api/server.js
+++ b/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}`); });
diff --git a/index.html b/index.html
index 3acc7a4..e298633 100644
--- a/index.html
+++ b/index.html
@@ -61,6 +61,20 @@
2. Select Apps
+
+
+
+