const express = require("express"); const path = require("path"); const Database = require("better-sqlite3"); const session = require("express-session"); const SQLiteStoreFactory = require("connect-sqlite3"); const { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } = require("@simplewebauthn/server"); const app = express(); const PORT = 8080; const RP_NAME = "James Van Boxtel"; const RP_ID = process.env.RP_ID || "jamesvanboxtel.com"; const ORIGIN = process.env.ORIGIN || "https://jamesvanboxtel.com"; // Database setup const dataDir = path.join(__dirname, "data"); const db = new Database(path.join(dataDir, "running.db")); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, username TEXT UNIQUE NOT NULL, approved INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS credentials ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), public_key BLOB NOT NULL, counter INTEGER DEFAULT 0, transports TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS progress ( key TEXT PRIMARY KEY, completed INTEGER DEFAULT 1, updated_by INTEGER REFERENCES users(id), updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); `); // Session setup const SQLiteStore = SQLiteStoreFactory(session); app.use( session({ store: new SQLiteStore({ dir: dataDir, db: "sessions.db" }), secret: process.env.SESSION_SECRET || "summit-stride-secret-change-me", resave: false, saveUninitialized: false, cookie: { secure: process.env.NODE_ENV === "production", httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days sameSite: "lax", }, }) ); app.use(express.json()); // In-memory challenge store (keyed by session ID, short-lived) const challengeStore = new Map(); function requireAuth(req, res, next) { if (!req.session.userId) { return res.status(401).json({ error: "Not authenticated" }); } const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.session.userId); if (!user || !user.approved) { return res.status(403).json({ error: "Not approved" }); } req.user = user; next(); } // --- Progress API --- app.get("/api/progress", (req, res) => { const rows = db.prepare("SELECT key, completed FROM progress").all(); const state = {}; for (const row of rows) { state[row.key] = !!row.completed; } res.json(state); }); app.post("/api/progress/:key", requireAuth, (req, res) => { const { key } = req.params; if (!/^\d+-\d+$/.test(key)) { return res.status(400).json({ error: "Invalid key format" }); } db.prepare( `INSERT INTO progress (key, completed, updated_by, updated_at) VALUES (?, 1, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET completed=1, updated_by=?, updated_at=CURRENT_TIMESTAMP` ).run(key, req.user.id, req.user.id); res.json({ ok: true }); }); app.delete("/api/progress/:key", requireAuth, (req, res) => { const { key } = req.params; db.prepare("DELETE FROM progress WHERE key = ?").run(key); res.json({ ok: true }); }); // --- Auth API --- app.get("/api/auth/me", (req, res) => { if (!req.session.userId) { return res.json({ authenticated: false }); } const user = db.prepare("SELECT id, username, approved FROM users WHERE id = ?").get(req.session.userId); if (!user) { return res.json({ authenticated: false }); } res.json({ authenticated: true, user }); }); app.post("/api/auth/logout", (req, res) => { req.session.destroy(() => { res.json({ ok: true }); }); }); // Registration app.post("/api/auth/register-options", async (req, res) => { try { const { username } = req.body; if (!username || typeof username !== "string" || username.length > 64) { return res.status(400).json({ error: "Invalid username" }); } let user = db.prepare("SELECT * FROM users WHERE username = ?").get(username); if (!user) { const result = db.prepare("INSERT INTO users (username, approved) VALUES (?, ?)").run( username, db.prepare("SELECT COUNT(*) as count FROM users").get().count === 0 ? 1 : 0 ); user = db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid); } const existingCreds = db .prepare("SELECT id, transports FROM credentials WHERE user_id = ?") .all(user.id); const options = await generateRegistrationOptions({ rpName: RP_NAME, rpID: RP_ID, userName: username, userID: Buffer.from(String(user.id)), attestationType: "none", excludeCredentials: existingCreds.map((c) => ({ id: c.id, transports: c.transports ? JSON.parse(c.transports) : undefined, })), authenticatorSelection: { residentKey: "preferred", userVerification: "preferred", }, }); challengeStore.set(req.sessionID, { challenge: options.challenge, userId: user.id }); setTimeout(() => challengeStore.delete(req.sessionID), 5 * 60 * 1000); res.json(options); } catch (err) { console.error("register-options error:", err); res.status(500).json({ error: "Registration failed" }); } }); app.post("/api/auth/register-verify", async (req, res) => { try { const stored = challengeStore.get(req.sessionID); if (!stored) { return res.status(400).json({ error: "No registration in progress" }); } const verification = await verifyRegistrationResponse({ response: req.body, expectedChallenge: stored.challenge, expectedOrigin: ORIGIN, expectedRPID: RP_ID, }); if (!verification.verified || !verification.registrationInfo) { return res.status(400).json({ error: "Verification failed" }); } const { credential } = verification.registrationInfo; db.prepare( "INSERT INTO credentials (id, user_id, public_key, counter, transports) VALUES (?, ?, ?, ?, ?)" ).run( credential.id, stored.userId, Buffer.from(credential.publicKey), credential.counter, JSON.stringify(req.body.response?.transports || []) ); challengeStore.delete(req.sessionID); const user = db.prepare("SELECT id, username, approved FROM users WHERE id = ?").get(stored.userId); req.session.userId = user.id; res.json({ verified: true, user }); } catch (err) { console.error("register-verify error:", err); res.status(500).json({ error: "Verification failed" }); } }); // Login app.post("/api/auth/login-options", async (req, res) => { try { const options = await generateAuthenticationOptions({ rpID: RP_ID, userVerification: "preferred", }); challengeStore.set(req.sessionID, { challenge: options.challenge }); setTimeout(() => challengeStore.delete(req.sessionID), 5 * 60 * 1000); res.json(options); } catch (err) { console.error("login-options error:", err); res.status(500).json({ error: "Login failed" }); } }); app.post("/api/auth/login-verify", async (req, res) => { try { const stored = challengeStore.get(req.sessionID); if (!stored) { return res.status(400).json({ error: "No login in progress" }); } const credentialId = req.body.id; const cred = db.prepare("SELECT * FROM credentials WHERE id = ?").get(credentialId); if (!cred) { return res.status(400).json({ error: "Unknown credential" }); } const verification = await verifyAuthenticationResponse({ response: req.body, expectedChallenge: stored.challenge, expectedOrigin: ORIGIN, expectedRPID: RP_ID, credential: { id: cred.id, publicKey: cred.public_key, counter: cred.counter, transports: cred.transports ? JSON.parse(cred.transports) : undefined, }, }); if (!verification.verified) { return res.status(400).json({ error: "Verification failed" }); } db.prepare("UPDATE credentials SET counter = ? WHERE id = ?").run( verification.authenticationInfo.newCounter, credentialId ); challengeStore.delete(req.sessionID); const user = db .prepare("SELECT id, username, approved FROM users WHERE id = ?") .get(cred.user_id); req.session.userId = user.id; res.json({ verified: true, user }); } catch (err) { console.error("login-verify error:", err); res.status(500).json({ error: "Verification failed" }); } }); // Serve static frontend app.use(express.static(path.join(__dirname, "public"))); // SPA fallback app.get("*", (req, res) => { if (req.path.startsWith("/api/")) { return res.status(404).json({ error: "Not found" }); } res.sendFile(path.join(__dirname, "public", "index.html")); }); app.listen(PORT, () => console.log(`Running app on :${PORT}`));