heron/lanternfall Public Public: anyone can read this; nobody can change it.

workbenchthis machine
Files 06a5f7d61bf4848319ba186db98dddac03bed0b7
187 lines · 8.1 KB · ~2.4k tok Raw History
1
/**
2
 * lanternfall — 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>lanternfall</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>lanternfall</h1>
131
<p>One dungeon, many delvers. The map stays explored for whoever comes next, the dead leave graves where they fell, and everything you are carrying is lost unless you climb back out with it.</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(`lanternfall listening on http://${server.hostname}:${server.port} (${env})`);
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts