wren/glyph Public Public: anyone can read this; nobody can change it.

forked from apps/glyph

workbenchthis machine
Files acd87b6b6146da11e0c9272c92a02a8e707adf4a
193 lines · 6.3 KB · ~1.8k tok Raw History
1
/**
2
 * The floor every response stands on, and the few things a route needs to
3
 * read a request safely. Nothing in here knows what a note is; who may
4
 * come in at all is `security.ts`.
5
 */
6
7
import { createHash } from "node:crypto";
8
import { existsSync, readFileSync } from "node:fs";
9
import { join } from "node:path";
10
import { env, publicOrigin } from "./env.ts";
11
12
// Scripts and styles come from this origin (inline styles allowed, inline
13
// scripts not), nothing frames us, browsers never guess a content type, and
14
// referrers stay on this site. Once served over https, always: browsers
15
// remember for a year. Anything but production asks not to be indexed: a
16
// preview is a draft, and its address goes away with its pull.
17
export const SECURITY: Record<string, string> = {
18
  "content-security-policy":
19
    "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
20
  "x-content-type-options": "nosniff",
21
  "referrer-policy": "strict-origin-when-cross-origin",
22
  "permissions-policy": "camera=(), microphone=(), geolocation=()",
23
  ...(publicOrigin.startsWith("https:")
24
    ? { "strict-transport-security": "max-age=31536000" }
25
    : {}),
26
  ...(env === "production" ? {} : { "x-robots-tag": "noindex" }),
27
};
28
29
export const escape = (s: string): string =>
30
  s.replace(
31
    /[&<>"']/g,
32
    (c) =>
33
      ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
34
        c
35
      ] ?? c,
36
  );
37
38
export const html = (
39
  body: string,
40
  status = 200,
41
  headers: Record<string, string> = {},
42
): Response =>
43
  new Response(body, {
44
    status,
45
    headers: {
46
      "content-type": "text/html; charset=utf-8",
47
      ...SECURITY,
48
      ...headers,
49
    },
50
  });
51
52
export const json = (data: unknown, status = 200): Response =>
53
  new Response(JSON.stringify(data), {
54
    status,
55
    headers: { "content-type": "application/json; charset=utf-8", ...SECURITY },
56
  });
57
58
export const text = (body: string, status = 200): Response =>
59
  new Response(body, {
60
    status,
61
    headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY },
62
  });
63
64
/** Back to the page, with a word for it to show once (`readNotice`). */
65
export const back = (notice?: string): Response =>
66
  new Response(null, {
67
    status: 303,
68
    headers: {
69
      location: "/",
70
      ...SECURITY,
71
      ...(notice === undefined
72
        ? {}
73
        : { "set-cookie": cookie("notice", encodeURIComponent(notice), 30) }),
74
    },
75
  });
76
77
/** The notice a redirect left, and the header that clears it. */
78
export function readNotice(request: Request): {
79
  notice?: string;
80
  clear: Record<string, string>;
81
} {
82
  const raw = /(?:^|;\s*)notice=([^;]*)/.exec(
83
    request.headers.get("cookie") ?? "",
84
  )?.[1];
85
  if (raw === undefined) return { clear: {} };
86
  return {
87
    notice: decodeURIComponent(raw),
88
    clear: { "set-cookie": cookie("notice", "", 0) },
89
  };
90
}
91
92
// Every cookie the app sets itself is HttpOnly and SameSite=Lax, and Secure
93
// wherever the public address is https.
94
const cookie = (name: string, value: string, maxAge: number): string =>
95
  `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`;
96
97
// A request body is read up to a cap and no further, counting the bytes as
98
// they arrive. A declared Content-Length is a claim: a chunked request has
99
// none, and a lie is free. Undefined means "too large" — refuse it. Past the
100
// cap the rest is drained and dropped so the connection stays usable for
101
// the next request; past eight times the cap it is cut off instead.
102
export const BODY_MAX = 64 * 1024;
103
export async function readBody(
104
  request: Request,
105
  max = BODY_MAX,
106
): Promise<Uint8Array | undefined> {
107
  const reader = request.body?.getReader();
108
  if (reader === undefined) return new Uint8Array();
109
  const parts: Uint8Array[] = [];
110
  let size = 0;
111
  for (;;) {
112
    const { done, value } = await reader.read();
113
    if (done) break;
114
    size += value.byteLength;
115
    if (size > max) {
116
      for (;;) {
117
        const next = await reader.read();
118
        if (next.done) return undefined;
119
        size += next.value.byteLength;
120
        if (size > max * 8) {
121
          await reader.cancel();
122
          return undefined;
123
        }
124
      }
125
    }
126
    parts.push(value);
127
  }
128
  const body = new Uint8Array(size);
129
  let at = 0;
130
  for (const part of parts) {
131
    body.set(part, at);
132
    at += part.byteLength;
133
  }
134
  return body;
135
}
136
137
/** The form fields of a capped body, decoded the way the browser sent them. */
138
export async function readForm(request: Request) {
139
  const body = await readBody(request);
140
  if (body === undefined) return undefined;
141
  return new Request(request.url, {
142
    method: "POST",
143
    headers: { "content-type": request.headers.get("content-type") ?? "" },
144
    body: new Uint8Array(body),
145
  }).formData();
146
}
147
148
/** The JSON of a capped body: `undefined` when too large, `null` when not JSON. */
149
export async function readJson(request: Request): Promise<unknown> {
150
  const body = await readBody(request);
151
  if (body === undefined) return undefined;
152
  try {
153
    return JSON.parse(new TextDecoder().decode(body));
154
  } catch {
155
    return null;
156
  }
157
}
158
159
/** A JSON object's fields, whatever the wire carried. */
160
export const fields = (value: unknown): Record<string, unknown> =>
161
  typeof value === "object" && value !== null && !Array.isArray(value)
162
    ? (value as Record<string, unknown>)
163
    : {};
164
165
// Files under ./static are served as they are. A page links each one by
166
// its content (`/static/app.css?v=3f2a9c1e`), and that address is cached
167
// for a year: a change is a new address, never a stale cache.
168
const STATIC = join(import.meta.dir, "..", "static");
169
const versions = new Map<string, string>();
170
export function asset(name: string): string {
171
  let v = versions.get(name);
172
  if (v === undefined) {
173
    v = createHash("sha256")
174
      .update(readFileSync(join(STATIC, name)))
175
      .digest("hex")
176
      .slice(0, 8);
177
    versions.set(name, v);
178
  }
179
  return `/static/${name}?v=${v}`;
180
}
181
export function staticFile(name: string, versioned: boolean): Response {
182
  const path = join(STATIC, name);
183
  if (name.includes("/") || name.includes("..") || !existsSync(path))
184
    return text("not found", 404);
185
  return new Response(Bun.file(path), {
186
    headers: {
187
      "cache-control": versioned
188
        ? "public, max-age=31536000, immutable"
189
        : "no-cache",
190
      ...SECURITY,
191
    },
192
  });
193
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts