Spaces:
Sleeping
Sleeping
File size: 5,872 Bytes
843f9e1 72d5cd7 843f9e1 72d5cd7 6793771 843f9e1 72d5cd7 6793771 843f9e1 72d5cd7 843f9e1 72d5cd7 843f9e1 6793771 843f9e1 72d5cd7 6793771 843f9e1 6793771 843f9e1 72d5cd7 843f9e1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | import express from "express";
import { existsSync, readFileSync } from "node:fs";
import { timingSafeEqual } from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { answer } from "./answer.js";
import { COORDINATOR_EMAIL, KB_VERSION, LOOKUP_RATE_LIMIT, MAX_MESSAGE_CHARS, RATE_LIMIT } from "./constants.js";
import { createInsights } from "./insights.js";
import { gatesRequested, skipGates } from "./localtest.js";
import { lookup } from "./lookup.js";
import { verifyRecaptcha } from "./recaptcha.js";
import { buildIndex } from "./retrieve.js";
const here = path.dirname(fileURLToPath(import.meta.url));
// The coordinator lists dates in server/data/deadlines.json: [{ "label": "...", "date": "YYYY-MM-DD" }].
// Only dates that have not passed are shown. An empty file shows nothing.
function upcomingDeadlines(file = path.resolve(here, "../data/deadlines.json")) {
try {
const today = new Date().toISOString().slice(0, 10);
return JSON.parse(readFileSync(file, "utf8"))
.filter((d) => typeof d.label === "string" && /^\d{4}-\d{2}-\d{2}$/.test(d.date) && d.date >= today)
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 3);
} catch {
return [];
}
}
const sameKey = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
export function createApp({ kb, llm, apiKey, verify = verifyRecaptcha, log = console.log, allowedOrigin = process.env.ALLOWED_ORIGIN, env = process.env } = {}) {
const index = buildIndex(kb);
const insights = createInsights(kb.segments.map((s) => s.id));
const allowed = String(allowedOrigin || "").split(",").map((o) => o.trim()).filter(Boolean);
const app = express();
app.disable("x-powered-by");
app.set("trust proxy", 1);
app.use(express.json({ limit: "16kb" }));
// Log method, path, status and time only. Never the request body.
app.use((req, res, next) => {
const t0 = Date.now();
res.on("finish", () => log(`${req.method} ${req.path} ${res.statusCode} ${Date.now() - t0}ms`));
next();
});
app.use((req, res, next) => {
const origin = req.headers.origin;
if (allowedOrigin && origin && allowed.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
}
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "no-referrer");
if (req.method === "OPTIONS") return res.sendStatus(204);
next();
});
const makeLimiter = ({ windowMs, max }) => {
const hits = new Map();
return (req, res, next) => {
if (skipGates(req, env)) return next();
const now = Date.now();
const rec = hits.get(req.ip);
if (!rec || now - rec.start > windowMs) hits.set(req.ip, { start: now, n: 1 });
else if (++rec.n > max) {
return res.status(429).json({ error: "Too many questions at once. Please wait a minute and try again." });
}
next();
};
};
const chatLimiter = makeLimiter(RATE_LIMIT);
const lookupLimiter = makeLimiter(LOOKUP_RATE_LIMIT);
const validate = (req, res, next) => {
const { message } = req.body ?? {};
if (typeof message !== "string" || !message.trim()) {
return res.status(400).json({ error: "Type a question first." });
}
if (message.length > MAX_MESSAGE_CHARS) {
return res.status(400).json({ error: `Please keep it under ${MAX_MESSAGE_CHARS} characters.` });
}
next();
};
app.get("/api/kb", (_req, res) => {
const { version, glossary, groups, segments } = kb;
res.json({
version,
glossary,
groups,
coordinatorEmail: COORDINATOR_EMAIL,
maxChars: MAX_MESSAGE_CHARS,
popular: insights.popular(),
deadlines: upcomingDeadlines(),
recaptchaSiteKey: gatesRequested(env) ? null : env.RECAPTCHA_SITE_KEY || null,
segments: segments.map(({ id, group, title, icon, blurb, steps }) => ({ id, group, title, icon, blurb, steps })),
});
});
// Step 1: is there a written document for this? Free, no model call.
app.post("/api/lookup", lookupLimiter, validate, (req, res) => {
res.json({ doc: lookup(index, req.body.message.trim()) });
});
// Step 2 (when the document did not help): the chatbot. Rate limited and reCAPTCHA gated.
app.post("/api/chat", chatLimiter, validate, async (req, res) => {
const check = skipGates(req, env) ? { ok: true, reason: "local-testing" } : await verify(req.body.captchaToken, req.ip);
if (!check.ok) {
return res.status(403).json({ error: "We could not confirm you are a person. Please try again.", reason: check.reason });
}
const { message, history } = req.body;
const out = await answer({ index, message: message.trim(), history, llm, ...(apiKey !== undefined && { apiKey }) });
res.json(out);
});
// Anonymous counts so the coordinator can see which answers get read and which fail.
app.post("/api/event", lookupLimiter, (req, res) => {
insights.record(req.body?.type, req.body?.id);
res.sendStatus(204);
});
app.get("/api/stats", (req, res) => {
const key = env.STATS_KEY;
const given = String(req.headers.authorization || "").replace(/^Bearer /, "");
if (!key) return res.sendStatus(404);
if (!given || !sameKey(given, key)) return res.status(401).json({ error: "Wrong key." });
res.json(insights.snapshot());
});
app.get("/api/health", (_req, res) => res.json({ ok: true, kbVersion: KB_VERSION }));
const dist = path.resolve(here, "../../web/dist");
if (existsSync(dist)) {
app.use(express.static(dist));
app.get("*", (_req, res) => res.sendFile(path.join(dist, "index.html")));
}
return app;
}
|