Files
roast_command_center/server/app.js
T

454 lines
14 KiB
JavaScript

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 = "[email protected]";
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("/", (_q, res) =>
res.sendFile(path.join(root, "public", "landing.html")),
);
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;
}