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;
}