/** * lanternfall — a small app with real data. * * Notes live in a SQLite file under FORGE_APP_DATA. Production and each * preview get their own directory, so a preview starts with production's * notes and never writes back to them. */ import { Database } from "bun:sqlite"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; const dataDir = process.env.FORGE_APP_DATA ?? "./data"; mkdirSync(dataDir, { recursive: true }); const db = new Database(join(dataDir, "app.db")); db.exec("PRAGMA journal_mode = WAL"); db.exec( "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))", ); const env = process.env.FORGE_APP_ENV ?? "local"; const pull = process.env.FORGE_APP_PULL; const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7); const escape = (s: string): string => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); // The security floor every response stands on. 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. // The app's public address is what the forge told us, never the socket's. const publicOrigin = new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin; const SECURITY = { "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=()", // Once served over https, always: browsers remember for a year. ...(publicOrigin.startsWith("https:") ? { "strict-transport-security": "max-age=31536000" } : {}), }; const html = (body: string, status = 200): Response => new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...SECURITY } }); // A browser on another site can make a person's browser POST here; it cannot // make that request look same-origin. Browsers say where a request came from // (Sec-Fetch-Site, or Origin); a request that names another site is refused. const sameOrigin = (request: Request): boolean => { const site = request.headers.get("sec-fetch-site"); if (site !== null) return site === "same-origin" || site === "none"; const origin = request.headers.get("origin"); return origin === null || origin === publicOrigin; }; // 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. const BODY_MAX = 64 * 1024; 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 // (a plain form or a multipart one), by handing the bytes back to a Request. async function readForm(request: Request): Promise { const body = await readBody(request); if (body === undefined) return undefined; return new Request(request.url, { method: "POST", headers: { "content-type": request.headers.get("content-type") ?? "" }, body, }).formData(); } function page(): string { const notes = db .query<{ id: number; body: string; created_at: string }, []>( "SELECT id, body, created_at FROM note ORDER BY id DESC LIMIT 100", ) .all(); const where = env === "preview" ? `preview of pull #${pull ?? "?"}` : env; return ` lanternfall

lanternfall

One dungeon, many delvers. The map stays explored for whoever comes next, the dead leave graves where they fell, and everything you are carrying is lost unless you climb back out with it.

${escape(where)} ${commit ? "@ " + commit : ""}

${notes.length === 0 ? "No notes yet. Notes written here stay in this environment's data." : `${notes.length} note${notes.length === 1 ? "" : "s"} in ${escape(where)} data.`}

`; } export const server = Bun.serve({ port: Number(process.env.PORT ?? 8080), hostname: process.env.HOST ?? "127.0.0.1", async fetch(request) { const url = new URL(request.url); if (request.method === "POST" && !sameOrigin(request)) return new Response("forbidden", { status: 403 }); if (request.method === "POST" && url.pathname === "/notes") { const form = await readForm(request); if (form === undefined) return new Response("too large", { status: 413, headers: SECURITY }); const body = String(form.get("body") ?? "").trim().slice(0, 500); if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body); return Response.redirect("/", 303); } // HEAD is GET without the body: a health check sees the app up. if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) { const full = html(page()); return request.method === "HEAD" ? new Response(null, { status: full.status, headers: full.headers }) : full; } // Files under ./static are served as they are, cached for a year: name // them by content (app.3f2a9c.js) and a change is a new name, never a // stale cache. The path is confined to the directory. if (url.pathname.startsWith("/static/") && !url.pathname.includes("..")) { const file = Bun.file(join(import.meta.dir, "..", url.pathname.slice(1))); if (await file.exists()) return new Response(file, { headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY }, }); } return new Response("not found", { status: 404, headers: SECURITY }); }, // A bug answers with the floor and nothing else: never the runtime's own // error page, which carries source and paths, and never without the // headers. The detail goes to the log, where it belongs. error(error) { console.error(error); return html("

Something went wrong

", 500); }, }); console.log(`lanternfall listening on http://${server.hostname}:${server.port} (${env})`);