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

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

Keyboard shortcuts