/** * The floor every response stands on, and the few things a route needs to * read a request safely. Nothing in here knows what a note is; who may * come in at all is `security.ts`. */ import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { env, publicOrigin } from "./env.ts"; // Scripts and styles come from this origin (inline styles allowed, inline // scripts not), nothing frames us, browsers never guess a content type, and // referrers stay on this site. Once served over https, always: browsers // remember for a year. Anything but production asks not to be indexed: a // preview is a draft, and its address goes away with its pull. export const SECURITY: Record = { "content-security-policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", "x-content-type-options": "nosniff", "referrer-policy": "strict-origin-when-cross-origin", "permissions-policy": "camera=(), microphone=(), geolocation=()", ...(publicOrigin.startsWith("https:") ? { "strict-transport-security": "max-age=31536000" } : {}), ...(env === "production" ? {} : { "x-robots-tag": "noindex" }), }; export const escape = (s: string): string => s.replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ c ] ?? c, ); export const html = ( body: string, status = 200, headers: Record = {}, ): Response => new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...SECURITY, ...headers, }, }); export const json = (data: unknown, status = 200): Response => new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json; charset=utf-8", ...SECURITY }, }); export const text = (body: string, status = 200): Response => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY }, }); /** Back to the page, with a word for it to show once (`readNotice`). */ export const back = (notice?: string): Response => new Response(null, { status: 303, headers: { location: "/", ...SECURITY, ...(notice === undefined ? {} : { "set-cookie": cookie("notice", encodeURIComponent(notice), 30) }), }, }); /** 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. */ export function go(path: string, notice?: string, cookies: string[] = []): Response { const headers = new Headers({ location: path.startsWith("/") && !path.startsWith("//") ? path : "/", ...SECURITY, }); if (notice !== undefined) headers.append("set-cookie", cookie("notice", encodeURIComponent(notice), 30)); for (const c of cookies) headers.append("set-cookie", c); return new Response(null, { status: 303, headers }); } /** One cookie the request carries, as sent. */ export const readCookie = (request: Request, name: string): string | undefined => new RegExp(`(?:^|;\\s*)${name}=([^;]*)`).exec(request.headers.get("cookie") ?? "")?.[1]; /** The notice a redirect left, and the header that clears it. */ export function readNotice(request: Request): { notice?: string; clear: Record; } { const raw = /(?:^|;\s*)notice=([^;]*)/.exec( request.headers.get("cookie") ?? "", )?.[1]; if (raw === undefined) return { clear: {} }; return { notice: decodeURIComponent(raw), clear: { "set-cookie": cookie("notice", "", 0) }, }; } // Every cookie the app sets itself is HttpOnly and SameSite=Lax, and Secure // wherever the public address is https. export const cookie = (name: string, value: string, maxAge: number): string => `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`; // A request body is read up to a cap and no further, counting the bytes as // they arrive. A declared Content-Length is a claim: a chunked request has // none, and a lie is free. Undefined means "too large" — refuse it. Past the // cap the rest is drained and dropped so the connection stays usable for // the next request; past eight times the cap it is cut off instead. export const BODY_MAX = 64 * 1024; /** What a whole note may weigh on the wire: 200,000 characters, any script. */ export const NOTE_BYTES = 1024 * 1024; export async function readBody( request: Request, max = BODY_MAX, ): Promise { const reader = request.body?.getReader(); if (reader === undefined) return new Uint8Array(); const parts: Uint8Array[] = []; let size = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > max) { for (;;) { const next = await reader.read(); if (next.done) return undefined; size += next.value.byteLength; if (size > max * 8) { await reader.cancel(); return undefined; } } } parts.push(value); } const body = new Uint8Array(size); let at = 0; for (const part of parts) { body.set(part, at); at += part.byteLength; } return body; } /** The form fields of a capped body, decoded the way the browser sent them. */ export async function readForm(request: Request, max = BODY_MAX) { const body = await readBody(request, max); if (body === undefined) return undefined; return new Request(request.url, { method: "POST", headers: { "content-type": request.headers.get("content-type") ?? "" }, body: new Uint8Array(body), }).formData(); } /** The JSON of a capped body: `undefined` when too large, `null` when not JSON. */ export async function readJson( request: Request, max = BODY_MAX, ): Promise { const body = await readBody(request, max); if (body === undefined) return undefined; try { return JSON.parse(new TextDecoder().decode(body)); } catch { return null; } } /** A JSON object's fields, whatever the wire carried. */ export const fields = (value: unknown): Record => typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : {}; // Files under ./static are served as they are. A page links each one by // its content (`/static/app.css?v=3f2a9c1e`), and that address is cached // for a year: a change is a new address, never a stale cache. const STATIC = join(import.meta.dir, "..", "static"); const versions = new Map(); export function asset(name: string): string { let v = versions.get(name); if (v === undefined) { v = createHash("sha256") .update(readFileSync(join(STATIC, name))) .digest("hex") .slice(0, 8); versions.set(name, v); } return `/static/${name}?v=${v}`; } export function staticFile(name: string, versioned: boolean): Response { const path = join(STATIC, name); if (name.includes("/") || name.includes("..") || !existsSync(path)) return text("not found", 404); return new Response(Bun.file(path), { headers: { "cache-control": versioned ? "public, max-age=31536000, immutable" : "no-cache", ...SECURITY, }, }); }