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

workbenchthis machine
Files 97e5e5dbb9fb18ff3b259cb5fd86864c3d5036c0
187 lines · 8.0 KB · ~2.4k tok Raw History
1
/**
2
 * oracle — a small app with real data.
3
 *
4
 * Notes live in a SQLite file under FORGE_APP_DATA. Production and each
5
 * preview get their own directory, so a preview starts with production's
6
 * notes and never writes back to them.
7
 */
8
9
import { Database } from "bun:sqlite";
10
import { mkdirSync } from "node:fs";
11
import { join } from "node:path";
12
13
const dataDir = process.env.FORGE_APP_DATA ?? "./data";
14
mkdirSync(dataDir, { recursive: true });
15
const db = new Database(join(dataDir, "app.db"));
16
db.exec("PRAGMA journal_mode = WAL");
17
db.exec(
18
  "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))",
19
);
20
21
const env = process.env.FORGE_APP_ENV ?? "local";
22
const pull = process.env.FORGE_APP_PULL;
23
const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7);
24
25
const escape = (s: string): string =>
26
  s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
27
28
// The security floor every response stands on. Scripts and styles come from
29
// this origin (inline styles allowed, inline scripts not), nothing frames us,
30
// browsers never guess a content type, and referrers stay on this site.
31
// The app's public address is what the forge told us, never the socket's.
32
const publicOrigin = new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin;
33
34
const SECURITY = {
35
  "content-security-policy":
36
    "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
37
  "x-content-type-options": "nosniff",
38
  "referrer-policy": "strict-origin-when-cross-origin",
39
  "permissions-policy": "camera=(), microphone=(), geolocation=()",
40
  // Once served over https, always: browsers remember for a year.
41
  ...(publicOrigin.startsWith("https:")
42
    ? { "strict-transport-security": "max-age=31536000" }
43
    : {}),
44
};
45
const html = (body: string, status = 200): Response =>
46
  new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...SECURITY } });
47
48
// A browser on another site can make a person's browser POST here; it cannot
49
// make that request look same-origin. Browsers say where a request came from
50
// (Sec-Fetch-Site, or Origin); a request that names another site is refused.
51
const sameOrigin = (request: Request): boolean => {
52
  const site = request.headers.get("sec-fetch-site");
53
  if (site !== null) return site === "same-origin" || site === "none";
54
  const origin = request.headers.get("origin");
55
  return origin === null || origin === publicOrigin;
56
};
57
58
// A request body is read up to a cap and no further, counting the bytes as
59
// they arrive. A declared Content-Length is a claim: a chunked request has
60
// none, and a lie is free. Undefined means "too large" — refuse it. Past the
61
// cap the rest is drained and dropped so the connection stays usable for
62
// the next request; past eight times the cap it is cut off instead.
63
const BODY_MAX = 64 * 1024;
64
async function readBody(request: Request, max = BODY_MAX): Promise<Uint8Array | undefined> {
65
  const reader = request.body?.getReader();
66
  if (reader === undefined) return new Uint8Array();
67
  const parts: Uint8Array[] = [];
68
  let size = 0;
69
  for (;;) {
70
    const { done, value } = await reader.read();
71
    if (done) break;
72
    size += value.byteLength;
73
    if (size > max) {
74
      for (;;) {
75
        const next = await reader.read();
76
        if (next.done) return undefined;
77
        size += next.value.byteLength;
78
        if (size > max * 8) {
79
          await reader.cancel();
80
          return undefined;
81
        }
82
      }
83
    }
84
    parts.push(value);
85
  }
86
  const body = new Uint8Array(size);
87
  let at = 0;
88
  for (const part of parts) {
89
    body.set(part, at);
90
    at += part.byteLength;
91
  }
92
  return body;
93
}
94
95
// The form fields of a capped body, decoded the way the browser sent them
96
// (a plain form or a multipart one), by handing the bytes back to a Request.
97
async function readForm(request: Request): Promise<FormData | undefined> {
98
  const body = await readBody(request);
99
  if (body === undefined) return undefined;
100
  return new Request(request.url, {
101
    method: "POST",
102
    headers: { "content-type": request.headers.get("content-type") ?? "" },
103
    body,
104
  }).formData();
105
}
106
107
function page(): string {
108
  const notes = db
109
    .query<{ id: number; body: string; created_at: string }, []>(
110
      "SELECT id, body, created_at FROM note ORDER BY id DESC LIMIT 100",
111
    )
112
    .all();
113
  const where =
114
    env === "preview" ? `preview of pull #${pull ?? "?"}` : env;
115
  return `<!doctype html>
116
<meta charset="utf-8">
117
<meta name="viewport" content="width=device-width, initial-scale=1">
118
<title>oracle</title>
119
<style>
120
  :root { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; }
121
  body { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }
122
  .env { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8rem;
123
         background: ${env === "production" ? "#16a34a" : "#2563eb"}; color: white; }
124
  form { display: flex; gap: .5rem; margin: 1.5rem 0; }
125
  input { flex: 1; padding: .6rem .8rem; font: inherit; border: 1px solid #8884; border-radius: .5rem; }
126
  button { padding: .6rem 1rem; font: inherit; border: 0; border-radius: .5rem; background: #111; color: white; }
127
  li { padding: .5rem 0; border-top: 1px solid #8883; list-style: none; }
128
  small { opacity: .6; }
129
</style>
130
<h1>oracle</h1>
131
<p>Ask%20it%20anything.%20It%20answers%20with%20conviction%2C%20remembers%20what%20it%20said%2C%20and%20keeps%20score%20of%20how%20often%20it%20was%20right.</p>
132
<p><span class="env">${escape(where)}</span> <small>${commit ? "@ " + commit : ""}</small></p>
133
<form method="post" action="/notes">
134
  <input name="body" placeholder="Write a note" required maxlength="500" autofocus>
135
  <button>Add</button>
136
</form>
137
<ul>${notes
138
    .map((n) => `<li>${escape(n.body)} <small>${n.created_at}</small></li>`)
139
    .join("")}</ul>
140
<p><small>${notes.length === 0 ? "No notes yet. Notes written here stay in this environment's data." : `${notes.length} note${notes.length === 1 ? "" : "s"} in ${escape(where)} data.`}</small></p>
141
`;
142
}
143
144
export const server = Bun.serve({
145
  port: Number(process.env.PORT ?? 8080),
146
  hostname: process.env.HOST ?? "127.0.0.1",
147
  async fetch(request) {
148
    const url = new URL(request.url);
149
    if (request.method === "POST" && !sameOrigin(request))
150
      return new Response("forbidden", { status: 403 });
151
    if (request.method === "POST" && url.pathname === "/notes") {
152
      const form = await readForm(request);
153
      if (form === undefined)
154
        return new Response("too large", { status: 413, headers: SECURITY });
155
      const body = String(form.get("body") ?? "").trim().slice(0, 500);
156
      if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body);
157
      return Response.redirect("/", 303);
158
    }
159
    // HEAD is GET without the body: a health check sees the app up.
160
    if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
161
      const full = html(page());
162
      return request.method === "HEAD"
163
        ? new Response(null, { status: full.status, headers: full.headers })
164
        : full;
165
    }
166
    // Files under ./static are served as they are, cached for a year: name
167
    // them by content (app.3f2a9c.js) and a change is a new name, never a
168
    // stale cache. The path is confined to the directory.
169
    if (url.pathname.startsWith("/static/") && !url.pathname.includes("..")) {
170
      const file = Bun.file(join(import.meta.dir, "..", url.pathname.slice(1)));
171
      if (await file.exists())
172
        return new Response(file, {
173
          headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY },
174
        });
175
    }
176
    return new Response("not found", { status: 404, headers: SECURITY });
177
  },
178
  // A bug answers with the floor and nothing else: never the runtime's own
179
  // error page, which carries source and paths, and never without the
180
  // headers. The detail goes to the log, where it belongs.
181
  error(error) {
182
    console.error(error);
183
    return html("<h1>Something went wrong</h1>", 500);
184
  },
185
});
186
187
console.log(`oracle listening on http://${server.hostname}:${server.port} (${env})`);
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts