Node/Express backend with SQLite storage, WebAuthn passkey auth, and React frontend built via Vite. Caddy routes /running/* via handle_path labels on docker-compose.
302 lines
8.7 KiB
JavaScript
302 lines
8.7 KiB
JavaScript
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}`));
|