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

workbenchthis machine
Files a80ad1c2e997ef7cb60c1375df4ad23cf52f6553
208 lines · 7.7 KB · ~2.2k tok Raw History
1
/**
2
 * lanternfall — the door. The rules are in `game.ts`, the dungeon in
3
 * `dungeon.ts` (pure), what happened in `db.ts`; here there is a table of
4
 * paths and a cookie.
5
 *
6
 * A delver belongs to one opaque HttpOnly cookie. It names nobody: the name
7
 * on the gravestone is derived from the cookie, which is also why a dead
8
 * delver's successor is somebody new rather than the same person again.
9
 */
10
11
import { randomBytes } from "node:crypto";
12
import { join } from "node:path";
13
import * as store from "./db.ts";
14
import { act, delverFor, isAction, viewFor } from "./game.ts";
15
import { endPage, gamePage, notFoundPage } from "./pages.ts";
16
import { traceServe } from "./otel.ts";
17
18
const db = store.open();
19
20
const chrome = {
21
  env: process.env.FORGE_APP_ENV ?? "local",
22
  pull: process.env.FORGE_APP_PULL,
23
  commit: (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7),
24
  ssh: process.env.FORGE_APP_SSH,
25
};
26
27
const HAND = "delver";
28
29
function handOf(request: Request): string | undefined {
30
  for (const part of (request.headers.get("cookie") ?? "").split(";")) {
31
    const [name, ...rest] = part.trim().split("=");
32
    if (name === HAND) {
33
      const value = rest.join("=");
34
      if (/^[a-f0-9]{32}$/.test(value)) return value;
35
    }
36
  }
37
  return undefined;
38
}
39
40
const newHand = (): string => randomBytes(16).toString("hex");
41
42
const handCookie = (hand: string): string =>
43
  `${HAND}=${hand}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax${
44
    publicOrigin.startsWith("https:") ? "; Secure" : ""
45
  }`;
46
47
// The app's public address is what the forge told us, never the socket's.
48
const publicOrigin = new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin;
49
50
// The security floor every response stands on. The one addition to the
51
// scaffold's: script-src is still 'self', and the page's only script is a
52
// file under /static — there is no inline script anywhere.
53
const SECURITY: Record<string, string> = {
54
  "content-security-policy":
55
    "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
56
  "x-content-type-options": "nosniff",
57
  "referrer-policy": "strict-origin-when-cross-origin",
58
  "permissions-policy": "camera=(), microphone=(), geolocation=()",
59
  ...(publicOrigin.startsWith("https:")
60
    ? { "strict-transport-security": "max-age=31536000" }
61
    : {}),
62
};
63
64
const html = (body: string, status = 200): Response =>
65
  new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...SECURITY } });
66
67
// A browser on another site can make a person's browser POST here; it cannot
68
// make that request look same-origin. Browsers say where a request came from
69
// (Sec-Fetch-Site, or Origin); a request that names another site is refused.
70
const sameOrigin = (request: Request): boolean => {
71
  const site = request.headers.get("sec-fetch-site");
72
  if (site !== null) return site === "same-origin" || site === "none";
73
  const origin = request.headers.get("origin");
74
  return origin === null || origin === publicOrigin;
75
};
76
77
// A request body is read up to a cap and no further, counting the bytes as
78
// they arrive. A declared Content-Length is a claim: a chunked request has
79
// none, and a lie is free. Undefined means "too large" — refuse it. Past the
80
// cap the rest is drained and dropped so the connection stays usable for
81
// the next request; past eight times the cap it is cut off instead.
82
const BODY_MAX = 64 * 1024;
83
async function readBody(request: Request, max = BODY_MAX): Promise<Uint8Array | undefined> {
84
  const reader = request.body?.getReader();
85
  if (reader === undefined) return new Uint8Array();
86
  const parts: Uint8Array[] = [];
87
  let size = 0;
88
  for (;;) {
89
    const { done, value } = await reader.read();
90
    if (done) break;
91
    size += value.byteLength;
92
    if (size > max) {
93
      for (;;) {
94
        const next = await reader.read();
95
        if (next.done) return undefined;
96
        size += next.value.byteLength;
97
        if (size > max * 8) {
98
          await reader.cancel();
99
          return undefined;
100
        }
101
      }
102
    }
103
    parts.push(value);
104
  }
105
  const body = new Uint8Array(size);
106
  let at = 0;
107
  for (const part of parts) {
108
    body.set(part, at);
109
    at += part.byteLength;
110
  }
111
  return body;
112
}
113
114
// The form fields of a capped body, decoded the way the browser sent them
115
// (a plain form or a multipart one), by handing the bytes back to a Request.
116
async function readForm(request: Request): Promise<FormData | undefined> {
117
  const body = await readBody(request);
118
  if (body === undefined) return undefined;
119
  return new Request(request.url, {
120
    method: "POST",
121
    headers: { "content-type": request.headers.get("content-type") ?? "" },
122
    body,
123
  }).formData();
124
}
125
126
/** Everything the screen needs for one delver, in one place. */
127
function screen(hand: string): string {
128
  const delver = delverFor(db, hand);
129
  const now = store.turn(db);
130
  return gamePage(
131
    chrome,
132
    delver,
133
    viewFor(db, delver),
134
    store.cacheState(db, delver.depth, now),
135
    store.heard(db, delver.id),
136
    store.recentGraves(db),
137
    store.topScores(db),
138
    store.countSeen(db),
139
  );
140
}
141
142
export const server = Bun.serve(traceServe({
143
  port: Number(process.env.PORT ?? 8080),
144
  hostname: process.env.HOST ?? "127.0.0.1",
145
  async fetch(request) {
146
    const url = new URL(request.url);
147
    if (request.method === "POST" && !sameOrigin(request))
148
      return new Response("forbidden", { status: 403 });
149
150
    if (request.method === "POST" && url.pathname === "/act") {
151
      const form = await readForm(request);
152
      if (form === undefined)
153
        return new Response("too large", { status: 413, headers: SECURITY });
154
      const hand = handOf(request) ?? newHand();
155
      const headers = new Headers({ "set-cookie": handCookie(hand), ...SECURITY });
156
      const wanted = String(form.get("action") ?? "");
157
      if (!isAction(wanted)) {
158
        headers.set("location", "/");
159
        return new Response(null, { status: 303, headers });
160
      }
161
      const result = act(db, hand, wanted);
162
      // A run that ended gets its own page once; the next action sends
163
      // somebody new down.
164
      headers.set("location", result.died ? "/died" : result.climbedOut ? "/out" : "/");
165
      return new Response(null, { status: 303, headers });
166
    }
167
168
    const reading = request.method === "GET" || request.method === "HEAD";
169
170
    if (reading && url.pathname === "/") {
171
      const seen = handOf(request);
172
      const hand = seen ?? newHand();
173
      const full = html(screen(hand));
174
      const headers = new Headers(full.headers);
175
      if (seen === undefined) headers.set("set-cookie", handCookie(hand));
176
      return request.method === "HEAD"
177
        ? new Response(null, { status: full.status, headers })
178
        : new Response(await full.text(), { status: full.status, headers });
179
    }
180
181
    if (reading && (url.pathname === "/died" || url.pathname === "/out")) {
182
      const hand = handOf(request);
183
      const last = hand === undefined ? null : store.lastDelver(db, hand);
184
      if (last === null || last.state === "delving") return Response.redirect("/", 303);
185
      return html(
186
        endPage(chrome, last, last.state === "returned", store.heard(db, last.id)),
187
      );
188
    }
189
190
    if (reading && url.pathname.startsWith("/static/") && !url.pathname.includes("..")) {
191
      const file = Bun.file(join(import.meta.dir, "..", url.pathname.slice(1)));
192
      if (await file.exists())
193
        return new Response(file, {
194
          headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY },
195
        });
196
    }
197
198
    return html(notFoundPage(chrome), 404);
199
  },
200
  error(error) {
201
    console.error(error);
202
    return html("<h1>Something went wrong</h1>", 500);
203
  },
204
}));
205
206
console.log(
207
  `lanternfall is open on http://${server.hostname}:${server.port} (${chrome.env}), turn ${store.turn(db)}`,
208
);
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts