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

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

Keyboard shortcuts