Add Summit Stride 5K training tracker at /running

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.
This commit is contained in:
JamBox
2026-02-15 22:01:58 -08:00
parent 4facad4c52
commit b217747ab6
13 changed files with 1109 additions and 0 deletions

301
apps/running/server.js Normal file
View File

@@ -0,0 +1,301 @@
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}`));