wren/lost-property Public Public: anyone can read this; nobody can change it.

workbenchthis machine
Files d66e67826a76bd71f7b0790b198ae93f73fe1614
167 lines · 7.2 KB · ~2.1k tok Raw History
1
/**
2
 * lost-property — a museum of lost things, as a small HTTP server.
3
 *
4
 * Data lives in SQLite under FORGE_APP_DATA (src/db.ts). The catalogue
5
 * number, vitrine and condition on a plaque are derived deterministically
6
 * from an item's id and description (src/museum.ts) — no AI, no lookup,
7
 * just pattern matching, so the same object always reads the same way.
8
 */
9
10
import { join } from "node:path";
11
import { addNote, createItem, getItem, listItems, listNotes } from "./db.ts";
12
import { renderCase, renderHome } from "./pages.ts";
13
import { traceServe } from "./otel.ts";
14
15
const env = process.env.FORGE_APP_ENV ?? "local";
16
const pull = process.env.FORGE_APP_PULL;
17
const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7);
18
const envLabel = env === "preview" ? `preview of pull #${pull ?? "?"}` : env;
19
20
// The security floor every response stands on. Scripts and styles come from
21
// this origin (inline styles allowed, inline scripts not), nothing frames us,
22
// browsers never guess a content type, and referrers stay on this site.
23
// The app's public address is what the forge told us, never the socket's.
24
const publicOrigin = new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin;
25
26
const SECURITY = {
27
  "content-security-policy":
28
    "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
29
  "x-content-type-options": "nosniff",
30
  "referrer-policy": "strict-origin-when-cross-origin",
31
  "permissions-policy": "camera=(), microphone=(), geolocation=()",
32
  // Once served over https, always: browsers remember for a year.
33
  ...(publicOrigin.startsWith("https:")
34
    ? { "strict-transport-security": "max-age=31536000" }
35
    : {}),
36
};
37
const html = (body: string, status = 200): Response =>
38
  new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...SECURITY } });
39
const text = (body: string, status: number): Response => new Response(body, { status, headers: SECURITY });
40
const asGet = (full: Response, method: string): Response =>
41
  method === "HEAD" ? new Response(null, { status: full.status, headers: full.headers }) : full;
42
43
// A browser on another site can make a person's browser POST here; it cannot
44
// make that request look same-origin. Browsers say where a request came from
45
// (Sec-Fetch-Site, or Origin); a request that names another site is refused.
46
const sameOrigin = (request: Request): boolean => {
47
  const site = request.headers.get("sec-fetch-site");
48
  if (site !== null) return site === "same-origin" || site === "none";
49
  const origin = request.headers.get("origin");
50
  return origin === null || origin === publicOrigin;
51
};
52
53
// A request body is read up to a cap and no further, counting the bytes as
54
// they arrive. A declared Content-Length is a claim: a chunked request has
55
// none, and a lie is free. Undefined means "too large" — refuse it. Past the
56
// cap the rest is drained and dropped so the connection stays usable for
57
// the next request; past eight times the cap it is cut off instead.
58
const BODY_MAX = 64 * 1024;
59
async function readBody(request: Request, max = BODY_MAX): Promise<Uint8Array | undefined> {
60
  const reader = request.body?.getReader();
61
  if (reader === undefined) return new Uint8Array();
62
  const parts: Uint8Array[] = [];
63
  let size = 0;
64
  for (;;) {
65
    const { done, value } = await reader.read();
66
    if (done) break;
67
    size += value.byteLength;
68
    if (size > max) {
69
      for (;;) {
70
        const next = await reader.read();
71
        if (next.done) return undefined;
72
        size += next.value.byteLength;
73
        if (size > max * 8) {
74
          await reader.cancel();
75
          return undefined;
76
        }
77
      }
78
    }
79
    parts.push(value);
80
  }
81
  const body = new Uint8Array(size);
82
  let at = 0;
83
  for (const part of parts) {
84
    body.set(part, at);
85
    at += part.byteLength;
86
  }
87
  return body;
88
}
89
90
// The form fields of a capped body, decoded the way the browser sent them
91
// (a plain form or a multipart one), by handing the bytes back to a Request.
92
async function readForm(request: Request): Promise<FormData | undefined> {
93
  const body = await readBody(request);
94
  if (body === undefined) return undefined;
95
  return new Request(request.url, {
96
    method: "POST",
97
    headers: { "content-type": request.headers.get("content-type") ?? "" },
98
    body,
99
  }).formData();
100
}
101
102
const ITEM_PATH = /^\/items\/(\d+)$/;
103
const NOTES_PATH = /^\/items\/(\d+)\/notes$/;
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
    const method = request.method;
111
    if (method === "POST" && !sameOrigin(request)) return text("forbidden", 403);
112
113
    if (method === "POST" && url.pathname === "/items") {
114
      const form = await readForm(request);
115
      if (form === undefined) return text("too large", 413);
116
      const description = String(form.get("description") ?? "").trim().slice(0, 500);
117
      if (!description) return Response.redirect("/", 303);
118
      const id = createItem(description);
119
      return Response.redirect(`/items/${id}`, 303);
120
    }
121
122
    const notesMatch = url.pathname.match(NOTES_PATH);
123
    if (method === "POST" && notesMatch) {
124
      const item = getItem(Number(notesMatch[1]));
125
      if (item === undefined) return text("not found", 404);
126
      const form = await readForm(request);
127
      if (form === undefined) return text("too large", 413);
128
      const kind = String(form.get("kind") ?? "");
129
      const body = String(form.get("body") ?? "").trim().slice(0, 500);
130
      if ((kind !== "sighting" && kind !== "match") || !body) return text("bad request", 400);
131
      addNote(item.id, kind, body);
132
      return Response.redirect(`/items/${item.id}`, 303);
133
    }
134
135
    // HEAD is GET without the body: a health check sees the app up.
136
    if (url.pathname === "/" && (method === "GET" || method === "HEAD"))
137
      return asGet(html(renderHome(listItems(), envLabel, commit)), method);
138
139
    const itemMatch = url.pathname.match(ITEM_PATH);
140
    if (itemMatch && (method === "GET" || method === "HEAD")) {
141
      const item = getItem(Number(itemMatch[1]));
142
      if (item === undefined) return text("not found", 404);
143
      return asGet(html(renderCase(item, listNotes(item.id), envLabel, commit)), method);
144
    }
145
146
    // Files under ./static are served as they are, cached for a year: name
147
    // them by content (app.3f2a9c.js) and a change is a new name, never a
148
    // stale cache. The path is confined to the directory.
149
    if (url.pathname.startsWith("/static/") && !url.pathname.includes("..")) {
150
      const file = Bun.file(join(import.meta.dir, "..", url.pathname.slice(1)));
151
      if (await file.exists())
152
        return new Response(file, {
153
          headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY },
154
        });
155
    }
156
    return text("not found", 404);
157
  },
158
  // A bug answers with the floor and nothing else: never the runtime's own
159
  // error page, which carries source and paths, and never without the
160
  // headers. The detail goes to the log, where it belongs.
161
  error(error) {
162
    console.error(error);
163
    return html("<h1>Something went wrong</h1>", 500);
164
  },
165
}));
166
167
console.log(`lost-property listening on http://${server.hostname}:${server.port} (${env})`);
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts