benjivers/notes Public Public: anyone can read this; nobody can change it.

Files 6f31b533b13d96cd4b38d39bc59da84ada17375b
213 lines · 7.2 KB · ~2.1k 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
/** To one of the app's own pages, with a word for it to show once. Only a path on this site: never an address a request supplied. */
78
export function go(path: string, notice?: string, cookies: string[] = []): Response {
79
  const headers = new Headers({
80
    location: path.startsWith("/") && !path.startsWith("//") ? path : "/",
81
    ...SECURITY,
82
  });
83
  if (notice !== undefined) headers.append("set-cookie", cookie("notice", encodeURIComponent(notice), 30));
84
  for (const c of cookies) headers.append("set-cookie", c);
85
  return new Response(null, { status: 303, headers });
86
}
87
88
/** One cookie the request carries, as sent. */
89
export const readCookie = (request: Request, name: string): string | undefined =>
90
  new RegExp(`(?:^|;\\s*)${name}=([^;]*)`).exec(request.headers.get("cookie") ?? "")?.[1];
91
92
/** The notice a redirect left, and the header that clears it. */
93
export function readNotice(request: Request): {
94
  notice?: string;
95
  clear: Record<string, string>;
96
} {
97
  const raw = /(?:^|;\s*)notice=([^;]*)/.exec(
98
    request.headers.get("cookie") ?? "",
99
  )?.[1];
100
  if (raw === undefined) return { clear: {} };
101
  return {
102
    notice: decodeURIComponent(raw),
103
    clear: { "set-cookie": cookie("notice", "", 0) },
104
  };
105
}
106
107
// Every cookie the app sets itself is HttpOnly and SameSite=Lax, and Secure
108
// wherever the public address is https.
109
export const cookie = (name: string, value: string, maxAge: number): string =>
110
  `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`;
111
112
// A request body is read up to a cap and no further, counting the bytes as
113
// they arrive. A declared Content-Length is a claim: a chunked request has
114
// none, and a lie is free. Undefined means "too large" — refuse it. Past the
115
// cap the rest is drained and dropped so the connection stays usable for
116
// the next request; past eight times the cap it is cut off instead.
117
export const BODY_MAX = 64 * 1024;
118
/** What a whole note may weigh on the wire: 200,000 characters, any script. */
119
export const NOTE_BYTES = 1024 * 1024;
120
export async function readBody(
121
  request: Request,
122
  max = BODY_MAX,
123
): Promise<Uint8Array | undefined> {
124
  const reader = request.body?.getReader();
125
  if (reader === undefined) return new Uint8Array();
126
  const parts: Uint8Array[] = [];
127
  let size = 0;
128
  for (;;) {
129
    const { done, value } = await reader.read();
130
    if (done) break;
131
    size += value.byteLength;
132
    if (size > max) {
133
      for (;;) {
134
        const next = await reader.read();
135
        if (next.done) return undefined;
136
        size += next.value.byteLength;
137
        if (size > max * 8) {
138
          await reader.cancel();
139
          return undefined;
140
        }
141
      }
142
    }
143
    parts.push(value);
144
  }
145
  const body = new Uint8Array(size);
146
  let at = 0;
147
  for (const part of parts) {
148
    body.set(part, at);
149
    at += part.byteLength;
150
  }
151
  return body;
152
}
153
154
/** The form fields of a capped body, decoded the way the browser sent them. */
155
export async function readForm(request: Request, max = BODY_MAX) {
156
  const body = await readBody(request, max);
157
  if (body === undefined) return undefined;
158
  return new Request(request.url, {
159
    method: "POST",
160
    headers: { "content-type": request.headers.get("content-type") ?? "" },
161
    body: new Uint8Array(body),
162
  }).formData();
163
}
164
165
/** The JSON of a capped body: `undefined` when too large, `null` when not JSON. */
166
export async function readJson(
167
  request: Request,
168
  max = BODY_MAX,
169
): Promise<unknown> {
170
  const body = await readBody(request, max);
171
  if (body === undefined) return undefined;
172
  try {
173
    return JSON.parse(new TextDecoder().decode(body));
174
  } catch {
175
    return null;
176
  }
177
}
178
179
/** A JSON object's fields, whatever the wire carried. */
180
export const fields = (value: unknown): Record<string, unknown> =>
181
  typeof value === "object" && value !== null && !Array.isArray(value)
182
    ? (value as Record<string, unknown>)
183
    : {};
184
185
// Files under ./static are served as they are. A page links each one by
186
// its content (`/static/app.css?v=3f2a9c1e`), and that address is cached
187
// for a year: a change is a new address, never a stale cache.
188
const STATIC = join(import.meta.dir, "..", "static");
189
const versions = new Map<string, string>();
190
export function asset(name: string): string {
191
  let v = versions.get(name);
192
  if (v === undefined) {
193
    v = createHash("sha256")
194
      .update(readFileSync(join(STATIC, name)))
195
      .digest("hex")
196
      .slice(0, 8);
197
    versions.set(name, v);
198
  }
199
  return `/static/${name}?v=${v}`;
200
}
201
export function staticFile(name: string, versioned: boolean): Response {
202
  const path = join(STATIC, name);
203
  if (name.includes("/") || name.includes("..") || !existsSync(path))
204
    return text("not found", 404);
205
  return new Response(Bun.file(path), {
206
    headers: {
207
      "cache-control": versioned
208
        ? "public, max-age=31536000, immutable"
209
        : "no-cache",
210
      ...SECURITY,
211
    },
212
  });
213
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts