import express from "express"; import path from "node:path"; import crypto from "node:crypto"; import bcrypt from "bcryptjs"; import { fetchPageText } from "./fetch-page.js"; import { runPrefill } from "./prefill.js"; import { parseAlog } from "./alog.js"; import { listAlogLibrary, readAlogFromLibrary } from "./alog-library.js"; const hash = (value) => crypto.createHash("sha256").update(value).digest("hex"); const token = () => crypto.randomBytes(32).toString("base64url"); const ADMIN_EMAIL = "snowspeeder@gmail.com"; const emailOf = (value) => String(value || "") .trim() .toLowerCase(); const PASSWORD_OK = (value) => typeof value === "string" && value.length >= 12 && value.length <= 256; /** Creates the HTTP app separately from listening, so tests can use an isolated database. */ export function createApp({ db, root, env = process.env } = {}) { const app = express(); const production = env.NODE_ENV === "production"; const cookieSecure = env.COOKIE_SECURE ? env.COOKIE_SECURE === "true" : production; const origin = env.APP_ORIGIN || (production ? "https://roast.srmr.xyz" : ""); const buckets = new Map(); const MAX_RATE_BUCKETS = 10_000; const rateLimit = (name, max, windowMs) => { if ( !Number.isInteger(max) || max < 1 || max > 1_000 || !Number.isInteger(windowMs) || windowMs < 1_000 || windowMs > 3_600_000 ) throw new Error("Invalid rate-limit configuration"); return (req, res, next) => { const key = `${name}:${req.ip}`; const now = Date.now(); for (const [bucketKey, bucket] of buckets) { if (bucket.reset <= now) buckets.delete(bucketKey); } if (buckets.size >= MAX_RATE_BUCKETS && !buckets.has(key)) return res.status(429).json({ ok: false, code: "rate_limited" }); const bucket = buckets.get(key) || { count: 0, reset: now + windowMs }; bucket.count++; buckets.set(key, bucket); res.set("RateLimit-Limit", String(max)); res.set("RateLimit-Reset", String(Math.ceil(bucket.reset / 1_000))); if (bucket.count > max) return res.status(429).json({ ok: false, code: "rate_limited" }); next(); }; }; app.disable("x-powered-by"); // Do not accept client-supplied forwarding headers unless the deployment explicitly // identifies its proxy. A numeric hop count is unsafe when the topology changes. app.set("trust proxy", env.TRUST_PROXY || false); app.use((req, res, next) => { if ( req.path.startsWith("/api/") || req.path === "/app" || req.path === "/admin" ) res.set("Cache-Control", "no-store, private"); res.set({ "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "strict-origin-when-cross-origin", "Permissions-Policy": "camera=(), microphone=(), geolocation=()", "Cross-Origin-Opener-Policy": "same-origin", "Content-Security-Policy": "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:", }); next(); }); app.use(express.json({ limit: "1mb" })); const cookie = (req, name) => Object.fromEntries( (req.headers.cookie || "") .split(";") .map((x) => x.trim().split("=")) .filter((x) => x[0]), )[name]; const setSessionCookie = (res, value, maxAge, csrfToken = "") => { res.cookie("rp_session", value, { httpOnly: true, secure: cookieSecure, sameSite: "lax", path: "/", maxAge, }); res.cookie("rp_csrf", csrfToken, { httpOnly: false, secure: cookieSecure, sameSite: "lax", path: "/", maxAge, }); }; async function session(req) { const raw = cookie(req, "rp_session"); if (!raw) return null; const r = await db.query( "SELECT s.csrf_hash,u.id,u.email,u.role FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.expires_at>now()", [hash(raw)], ); return r.rows[0] || null; } async function requireAuth(req, res, next) { try { req.user = await session(req); if (!req.user) return res.status(401).json({ ok: false, code: "unauthorized" }); next(); } catch (e) { next(e); } } const csrf = (req, res, next) => { if (origin && req.get("origin") && req.get("origin") !== origin) return res.status(403).json({ ok: false, code: "bad_origin" }); const value = req.get("x-csrf-token"); if ( !value || value !== cookie(req, "rp_csrf") || !req.user || !crypto.timingSafeEqual( Buffer.from(hash(value)), Buffer.from(req.user.csrf_hash), ) ) return res.status(403).json({ ok: false, code: "csrf_failed" }); next(); }; const admin = (req, res, next) => req.user.role === "admin" ? next() : res.status(403).json({ ok: false, code: "forbidden" }); const createSession = async (user) => { const raw = token(), csrfToken = token(); await db.query( "INSERT INTO sessions(token_hash,user_id,csrf_hash,expires_at) VALUES($1,$2,$3,now()+interval '14 days')", [hash(raw), user.id, hash(csrfToken)], ); return { raw, csrfToken }; }; app.post( "/api/auth/signup", rateLimit("signup", 8, 60_000), async (req, res, next) => { try { const email = emailOf(req.body.email), password = req.body.password; if (!/^\S+@\S+\.\S+$/.test(email) || !PASSWORD_OK(password)) return res.status(400).json({ ok: false, code: "invalid_credentials", error: "Use a valid email and a password of at least 12 characters.", }); const setting = await db.query( "SELECT value FROM app_settings WHERE key='signup_enabled'", ); if (setting.rows[0]?.value !== "true") return res.status(403).json({ ok: false, code: "signup_disabled" }); const password_hash = await bcrypt.hash(password, 12); const user = ( await db.query( "INSERT INTO users(email,password_hash) VALUES($1,$2) RETURNING id,email,role", [email, password_hash], ) ).rows[0]; const s = await createSession(user); setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken); res.status(201).json({ ok: true, user: { email: user.email, role: user.role }, csrfToken: s.csrfToken, }); } catch (e) { if (e.code === "23505") return res.status(409).json({ ok: false, code: "email_exists" }); next(e); } }, ); app.post( "/api/auth/bootstrap", rateLimit("bootstrap", 4, 60_000), async (req, res, next) => { try { const exists = await db.query("SELECT 1 FROM users WHERE email=$1", [ ADMIN_EMAIL, ]); // Once the administrator exists, the deployment no longer needs to retain // the bootstrap secret. Do not reveal whether a supplied token was valid. if (exists.rowCount) return res.status(409).json({ ok: false, code: "bootstrap_used" }); const bootstrap = String(req.body.setupToken || ""); if (!env.BOOTSTRAP_SETUP_TOKEN) return res .status(503) .json({ ok: false, code: "bootstrap_unavailable" }); if ( bootstrap.length !== env.BOOTSTRAP_SETUP_TOKEN.length || !crypto.timingSafeEqual( Buffer.from(bootstrap), Buffer.from(env.BOOTSTRAP_SETUP_TOKEN), ) ) return res .status(403) .json({ ok: false, code: "invalid_setup_token" }); if ( emailOf(req.body.email) !== ADMIN_EMAIL || !PASSWORD_OK(req.body.password) ) return res .status(400) .json({ ok: false, code: "invalid_credentials" }); const user = ( await db.query( "INSERT INTO users(email,password_hash,role) VALUES($1,$2,'admin') RETURNING id,email,role", [ADMIN_EMAIL, await bcrypt.hash(req.body.password, 12)], ) ).rows[0]; const s = await createSession(user); setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken); res.status(201).json({ ok: true, user: { email: user.email, role: user.role }, csrfToken: s.csrfToken, }); } catch (e) { next(e); } }, ); app.post( "/api/auth/login", rateLimit("login", 10, 60_000), async (req, res, next) => { try { const user = ( await db.query( "SELECT id,email,role,password_hash FROM users WHERE email=$1", [emailOf(req.body.email)], ) ).rows[0]; if ( !user || !(await bcrypt.compare( String(req.body.password || ""), user.password_hash, )) ) return res .status(401) .json({ ok: false, code: "invalid_credentials" }); const s = await createSession(user); setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken); res.json({ ok: true, user: { email: user.email, role: user.role }, csrfToken: s.csrfToken, }); } catch (e) { next(e); } }, ); app.get("/api/auth/me", requireAuth, (req, res) => res.json({ ok: true, user: { id: req.user.id, email: req.user.email, role: req.user.role }, }), ); app.post("/api/auth/logout", requireAuth, csrf, async (req, res, next) => { try { await db.query("DELETE FROM sessions WHERE token_hash=$1", [ hash(cookie(req, "rp_session")), ]); setSessionCookie(res, "", 0, ""); res.json({ ok: true }); } catch (e) { next(e); } }); app.get("/api/plans", requireAuth, async (req, res, next) => { try { res.json({ ok: true, plans: ( await db.query( "SELECT id,plan,created_at,updated_at FROM roast_plans WHERE user_id=$1 ORDER BY updated_at DESC", [req.user.id], ) ).rows, }); } catch (e) { next(e); } }); app.post("/api/plans", requireAuth, csrf, async (req, res, next) => { try { if (!req.body.plan || typeof req.body.plan !== "object") return res.status(400).json({ ok: false, code: "bad_plan" }); const p = ( await db.query( "INSERT INTO roast_plans(user_id,plan) VALUES($1,$2) RETURNING id,plan,created_at,updated_at", [req.user.id, req.body.plan], ) ).rows[0]; res.status(201).json({ ok: true, plan: p }); } catch (e) { next(e); } }); app.put("/api/plans/:id", requireAuth, csrf, async (req, res, next) => { try { const r = await db.query( "UPDATE roast_plans SET plan=$1,updated_at=now() WHERE id=$2 AND user_id=$3 RETURNING id,plan,updated_at", [req.body.plan, req.params.id, req.user.id], ); if (!r.rowCount) return res.status(404).json({ ok: false, code: "not_found" }); res.json({ ok: true, plan: r.rows[0] }); } catch (e) { next(e); } }); app.get("/api/admin/users", requireAuth, admin, async (req, res, next) => { try { res.json({ ok: true, signupEnabled: ( await db.query( "SELECT value FROM app_settings WHERE key='signup_enabled'", ) ).rows[0]?.value === "true", users: ( await db.query( "SELECT u.id,u.email,u.role,u.created_at,count(p.id)::int AS plan_count FROM users u LEFT JOIN roast_plans p ON p.user_id=u.id GROUP BY u.id ORDER BY u.created_at", ) ).rows, }); } catch (e) { next(e); } }); app.get("/api/admin/plans", requireAuth, admin, async (req, res, next) => { try { res.json({ ok: true, plans: ( await db.query( "SELECT p.id,p.plan,p.updated_at,u.email FROM roast_plans p JOIN users u ON u.id=p.user_id ORDER BY p.updated_at DESC", ) ).rows, }); } catch (e) { next(e); } }); app.put( "/api/admin/signup-enabled", requireAuth, csrf, admin, async (req, res, next) => { try { if (typeof req.body.enabled !== "boolean") return res.status(400).json({ ok: false, code: "bad_request" }); await db.query( "UPDATE app_settings SET value=$1 WHERE key='signup_enabled'", [String(req.body.enabled)], ); res.json({ ok: true }); } catch (e) { next(e); } }, ); // Existing integrations remain authenticated but CSRF-protected for writes. app.post("/api/prefill", requireAuth, csrf, async (req, res) => { const url = typeof req.body?.url === "string" ? req.body.url.trim() : ""; if (!url) return res .status(400) .json({ ok: false, code: "bad_url", error: "Missing url." }); try { const result = await runPrefill(await fetchPageText(url)); res.json({ ok: true, ...result }); } catch (err) { const code = err.code ?? "prefill_failed"; res .status( code === "fetch_timeout" ? 504 : code === "bad_url" ? 400 : code === "no_model" ? 503 : 422, ) .json({ ok: false, code, error: err.message }); } }); app.post("/api/alog", requireAuth, csrf, (req, res) => { try { if (typeof req.body?.content !== "string" || !req.body.content.trim()) return res.status(400).json({ ok: false, code: "bad_request" }); res.json({ ok: true, ...parseAlog(req.body.content, req.body.filename ?? "upload.alog"), }); } catch (err) { res .status(422) .json({ ok: false, code: "unparseable_alog", error: err.message }); } }); app.get("/api/alog/library", requireAuth, async (_q, res) => res.json({ ok: true, files: await listAlogLibrary() }), ); app.get("/api/alog/library/:filename", requireAuth, async (req, res) => { try { res.json({ ok: true, ...(await readAlogFromLibrary(req.params.filename)), }); } catch (e) { res .status(e.code === "not_found" ? 404 : 400) .json({ ok: false, code: e.code, error: e.message }); } }); app.get("/", async (req, res, next) => { try { // Returning users should not be left on the sign-in page after a successful login. if (await session(req)) return res.status(302).location("/app").end(); res.set("Cache-Control", "no-store, private"); res.sendFile(path.join(root, "public", "landing.html")); } catch (error) { next(error); } }); app.get("/app", requireAuth, (_q, res) => res.sendFile(path.join(root, "public", "index.html")), ); app.get("/admin", requireAuth, admin, (_q, res) => res.sendFile(path.join(root, "public", "admin.html")), ); app.use(express.static(path.join(root, "public"))); app.use("/shared", express.static(path.join(root, "shared"))); app.use((err, _req, res, _next) => { console.error(err); res.status(500).json({ ok: false, code: "internal_error" }); }); return app; }