quill/oracle Public Public: anyone can read this; nobody can change it.

workbenchthis machine
Files main
187 lines · 7.4 KB · ~2.2k tok Raw History
1
/**
2
 * oracle — the door. The security floor, the body caps and the table of
3
 * paths live here; the mind is in `oracle.ts`, the ledger in `db.ts` and
4
 * every page in `pages.ts`.
5
 *
6
 * The ledger is a SQLite file under FORGE_APP_DATA. Production and each
7
 * preview get their own directory, so a preview starts with a copy of
8
 * production's verdicts and never writes back to them.
9
 */
10
11
import { join } from "node:path";
12
import * as ledger from "./db.ts";
13
import { isOutcome } from "./oracle.ts";
14
import { homePage, ledgerPage, notFoundPage, verdictPage } from "./pages.ts";
15
import { traceServe } from "./otel.ts";
16
17
const db = ledger.open();
18
19
const chrome = {
20
  env: process.env.FORGE_APP_ENV ?? "local",
21
  pull: process.env.FORGE_APP_PULL,
22
  commit: (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7),
23
  ssh: process.env.FORGE_APP_SSH,
24
};
25
26
// The security floor every response stands on. Scripts and styles come from
27
// this origin (inline styles allowed, inline scripts not), nothing frames us,
28
// browsers never guess a content type, and referrers stay on this site.
29
// The app's public address is what the forge told us, never the socket's.
30
const publicOrigin = new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin;
31
32
const SECURITY = {
33
  "content-security-policy":
34
    "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
35
  "x-content-type-options": "nosniff",
36
  "referrer-policy": "strict-origin-when-cross-origin",
37
  "permissions-policy": "camera=(), microphone=(), geolocation=()",
38
  // Once served over https, always: browsers remember for a year.
39
  ...(publicOrigin.startsWith("https:")
40
    ? { "strict-transport-security": "max-age=31536000" }
41
    : {}),
42
};
43
const html = (body: string, status = 200): Response =>
44
  new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...SECURITY } });
45
46
// A browser on another site can make a person's browser POST here; it cannot
47
// make that request look same-origin. Browsers say where a request came from
48
// (Sec-Fetch-Site, or Origin); a request that names another site is refused.
49
const sameOrigin = (request: Request): boolean => {
50
  const site = request.headers.get("sec-fetch-site");
51
  if (site !== null) return site === "same-origin" || site === "none";
52
  const origin = request.headers.get("origin");
53
  return origin === null || origin === publicOrigin;
54
};
55
56
// A request body is read up to a cap and no further, counting the bytes as
57
// they arrive. A declared Content-Length is a claim: a chunked request has
58
// none, and a lie is free. Undefined means "too large" — refuse it. Past the
59
// cap the rest is drained and dropped so the connection stays usable for
60
// the next request; past eight times the cap it is cut off instead.
61
const BODY_MAX = 64 * 1024;
62
async function readBody(request: Request, max = BODY_MAX): Promise<Uint8Array | undefined> {
63
  const reader = request.body?.getReader();
64
  if (reader === undefined) return new Uint8Array();
65
  const parts: Uint8Array[] = [];
66
  let size = 0;
67
  for (;;) {
68
    const { done, value } = await reader.read();
69
    if (done) break;
70
    size += value.byteLength;
71
    if (size > max) {
72
      for (;;) {
73
        const next = await reader.read();
74
        if (next.done) return undefined;
75
        size += next.value.byteLength;
76
        if (size > max * 8) {
77
          await reader.cancel();
78
          return undefined;
79
        }
80
      }
81
    }
82
    parts.push(value);
83
  }
84
  const body = new Uint8Array(size);
85
  let at = 0;
86
  for (const part of parts) {
87
    body.set(part, at);
88
    at += part.byteLength;
89
  }
90
  return body;
91
}
92
93
// The form fields of a capped body, decoded the way the browser sent them
94
// (a plain form or a multipart one), by handing the bytes back to a Request.
95
async function readForm(request: Request): Promise<FormData | undefined> {
96
  const body = await readBody(request);
97
  if (body === undefined) return undefined;
98
  return new Request(request.url, {
99
    method: "POST",
100
    headers: { "content-type": request.headers.get("content-type") ?? "" },
101
    body,
102
  }).formData();
103
}
104
105
export const server = Bun.serve(traceServe({
106
  port: Number(process.env.PORT ?? 8080),
107
  hostname: process.env.HOST ?? "127.0.0.1",
108
  async fetch(request) {
109
    const url = new URL(request.url);
110
    if (request.method === "POST" && !sameOrigin(request))
111
      return new Response("forbidden", { status: 403 });
112
113
    // Ask. An empty question is not a question; the same question is not a
114
    // new one, which `ledger.ask` enforces in one statement.
115
    if (request.method === "POST" && url.pathname === "/ask") {
116
      const form = await readForm(request);
117
      if (form === undefined)
118
        return new Response("too large", { status: 413, headers: SECURITY });
119
      const question = String(form.get("question") ?? "").trim();
120
      if (question === "") return Response.redirect("/", 303);
121
      const row = ledger.ask(db, question);
122
      return Response.redirect(`/v/${row.id}`, 303);
123
    }
124
125
    // Score a verdict. Only the three words count; anything else is a
126
    // client inventing states for the ledger to hold.
127
    const judging = url.pathname.match(/^\/v\/(\d{1,15})\/judge$/);
128
    if (request.method === "POST" && judging) {
129
      const form = await readForm(request);
130
      if (form === undefined)
131
        return new Response("too large", { status: 413, headers: SECURITY });
132
      const outcome = String(form.get("outcome") ?? "");
133
      const id = Number(judging[1]);
134
      if (!isOutcome(outcome) || ledger.get(db, id) === null)
135
        return html(notFoundPage(chrome), 404);
136
      ledger.judge(db, id, outcome);
137
      return Response.redirect(`/v/${id}`, 303);
138
    }
139
140
    const reading = request.method === "GET" || request.method === "HEAD";
141
142
    if (reading && url.pathname === "/") {
143
      const full = html(homePage(chrome, ledger.recent(db), ledger.scored(db)));
144
      return request.method === "HEAD"
145
        ? new Response(null, { status: full.status, headers: full.headers })
146
        : full;
147
    }
148
149
    const permalink = url.pathname.match(/^\/v\/(\d{1,15})$/);
150
    if (reading && permalink) {
151
      const row = ledger.get(db, Number(permalink[1]));
152
      return row === null
153
        ? html(notFoundPage(chrome), 404)
154
        : html(verdictPage(chrome, row));
155
    }
156
157
    if (reading && url.pathname === "/ledger") {
158
      const show = url.searchParams.get("show") ?? "all";
159
      const filter = ledger.isFilter(show) ? show : "all";
160
      return html(ledgerPage(chrome, ledger.ledger(db, filter), filter));
161
    }
162
163
    // Files under ./static are served as they are, cached for a year: name
164
    // them by content (app.3f2a9c.js) and a change is a new name, never a
165
    // stale cache. The path is confined to the directory.
166
    if (reading && url.pathname.startsWith("/static/") && !url.pathname.includes("..")) {
167
      const file = Bun.file(join(import.meta.dir, "..", url.pathname.slice(1)));
168
      if (await file.exists())
169
        return new Response(file, {
170
          headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY },
171
        });
172
    }
173
174
    return html(notFoundPage(chrome), 404);
175
  },
176
  // A bug answers with the floor and nothing else: never the runtime's own
177
  // error page, which carries source and paths, and never without the
178
  // headers. The detail goes to the log, where it belongs.
179
  error(error) {
180
    console.error(error);
181
    return html("<h1>Something went wrong</h1>", 500);
182
  },
183
}));
184
185
console.log(
186
  `the oracle is listening on http://${server.hostname}:${server.port} (${chrome.env})`,
187
);
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts