feat: harden authenticated deployment
This commit is contained in:
+437
-41
@@ -10,48 +10,444 @@ 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;
|
||||
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 rateLimit = (name, max, windowMs) => (req, res, next) => {
|
||||
const key = `${name}:${req.ip}`; const now = Date.now(); const b = buckets.get(key) || { count: 0, reset: now + windowMs };
|
||||
if (now > b.reset) Object.assign(b, { count: 0, reset: now + windowMs });
|
||||
b.count++; buckets.set(key, b); res.set("RateLimit-Limit", String(max));
|
||||
if (b.count > max) return res.status(429).json({ ok: false, code: "rate_limited" }); next();
|
||||
};
|
||||
app.disable("x-powered-by");
|
||||
app.set("trust proxy", 1);
|
||||
app.use((req, res, next) => { 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 bootstrap=String(req.body.setupToken||""); if (!env.BOOTSTRAP_SETUP_TOKEN || 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 exists=await db.query("SELECT 1 FROM users WHERE email=$1",[ADMIN_EMAIL]); if(exists.rowCount) return res.status(409).json({ok:false,code:"bootstrap_used"}); 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;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user