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

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

Keyboard shortcuts