clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / lib / private-server.ts
private-server.ts 27.3K
1// Private repos' pages, for the people who may read them. Caddy serves www2// as plain files and hands this server every request for a file that isn't3// there (bin/forge server caddyfile), so it needs no list of private repos.4// It serves a private repo's pages from the private root (bin/forge5// build_private), with the same layout and the same headers as Caddy gives6// pages (ETag, 304s, byte ranges for code.db) except Cache-Control, which7// is "private, no-cache": the same revalidation, kept out of shared caches.8// Its release downloads (/<owner>/<repo>/releases/download/...) are sent9// as attachments, as Caddy sends a public repo's.10//11// A reader is the repo's owner, a collaborator or a name in its readers12// (lib/access.ts canRead, the rule bin/forge applies) who is still a user of13// the forge and is signed in with a passkey (the session cookie) or shows a14// token: Authorization: Bearer <token>, or basic auth with the token as the15// password (or the user name), for curl and git. Everyone else, a page that16// isn't there, and a repo that isn't, get the same 404, byte for byte; so17// does any missing page of www, since Caddy sends those here too.18//19// /login/ sign in, add a passkey from an ssh link, sign out (lib/login.ts);20// join from an invite's link (POST /login/invite/...)21// /login/whoami who is signed in, and their CSRF token, for review pages' script22// /private/ the private repos you can read23// /assets/<file> the forge's assets, for pages here before a build has shipped them24// /<owner>/<repo>/ a private repo's pages25// /auth/git Caddy's forward_auth for HTTPS clones of repos without26// git-daemon-export-ok: may this token read X-Forge-Repo?27// POST /<owner>/<repo>/reviews/<id>/comment, .../approve28// a comment or approval from a review page, public or29// private, made the way ssh makes it (discuss, below)30//31// node lib/private-server.ts (FORGE_ROOT, FORGE_BASE_URL, FORGE_PRIVATE_PORT: 8093)32import http from 'node:http';33import fs from 'node:fs';34import path from 'node:path';35import { spawn } from 'node:child_process';36import { fileURLToPath } from 'node:url';37import { INVITE_EXPIRED, Identity, SESSION_TTL, TOKEN_PREFIX, cookieValue, csrfToken, sameSecret, sessionCookie, type Session, type Token } from './identity.ts';38import { canRead, isPublic, readable, repoDir, users } from './access.ts';39import { DOWNLOAD, DOWNLOAD_HEADERS, resolve, sendFile } from './static.ts';40import { Login, LoginError, agentName, browserId, challengeCookie, newBrowserId, readBody, relyingParty } from './login.ts';41import { Window, clientIp, timeouts } from './limits.ts';42import { pageRenderer, type Render } from './render-page.ts';43import { assets } from './assets.ts';44import { summarize } from './repo.ts';45import { config as forgeConfig } from './forge.ts';4647export interface PrivateConfig {48 root: string;49 state: string;50 // The private root (bin/forge build_private).51 priv: string;52 // The forge's origin, which passkeys belong to; without it, no sign-in.53 baseUrl: string;54 name?: string;55 ssh?: string;56}5758export function privateConfig(env: NodeJS.ProcessEnv = process.env): PrivateConfig {59 const root = path.resolve(env.FORGE_ROOT || 'demo/root');60 const state = path.join(root, '.forge');61 return {62 root, state, priv: env.FORGE_PRIVATE_WWW || path.join(state, 'private', 'www'), baseUrl: (env.FORGE_BASE_URL || '').replace(/\/$/, ''),63 name: env.FORGE_NAME || 'forge', ssh: env.FORGE_SSH || 'git@localhost',64 };65}6667// What every missing or unreadable page gets.68export const NOT_FOUND = Buffer.from(69 '<!DOCTYPE html>\n<html lang="en">\n<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Not found</title></head>\n' +70 '<body><h1>Not found</h1><p>There is no page here. Private repos show their pages only to their readers, <a href="/login/">signed in</a>.</p><p><a href="/">All repositories</a></p></body>\n</html>\n',71);7273export function notFound(res: http.ServerResponse, head = false): void {74 res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache', 'Content-Length': NOT_FOUND.length });75 res.end(head ? undefined : NOT_FOUND);76}7778// Who is asking: a token's holder, or a signed-in session's user.79export interface Who {80 user: string;81 token: Token | null;82 session: Session | null;83 // The session cookie's value, and whether to send the cookie again (its84 // lifetime slid forward).85 secret: string | null;86 refresh: boolean;87}8889// A token from Authorization: Bearer, or Basic with the token as the90// password or the user name (git sends user:password from the URL).91export function presented(req: http.IncomingMessage): string | null {92 const h = req.headers.authorization ?? '';93 const bearer = /^Bearer +(\S+)$/i.exec(h);94 if (bearer) return bearer[1];95 const basic = /^Basic +([A-Za-z0-9+/=]+)$/i.exec(h);96 if (!basic) return null;97 const text = Buffer.from(basic[1], 'base64').toString('utf8');98 const i = text.indexOf(':');99 const [name, pass] = i < 0 ? [text, ''] : [text.slice(0, i), text.slice(i + 1)];100 return pass.startsWith(TOKEN_PREFIX) ? pass : name.startsWith(TOKEN_PREFIX) ? name : null;101}102103export class Gate {104 cfg: PrivateConfig;105 private db: Identity | null = null;106 constructor(cfg: PrivateConfig) {107 this.cfg = cfg;108 }109110 get id(): Identity {111 return (this.db ??= Identity.open(this.cfg.state)!);112 }113114 isUser(name: string): boolean {115 return users(this.cfg.state).has(name);116 }117118 // The token's holder, if it is a live token of a user who still has keys.119 byToken(value: string | null): Who | null {120 if (!value) return null;121 const t = this.id.token(value);122 if (!t || !this.isUser(t.user)) return null;123 this.id.usedToken(t);124 return { user: t.user, token: t, session: null, secret: null, refresh: false };125 }126127 bySession(secret: string | null): Who | null {128 if (!secret) return null;129 const s = this.id.session(secret);130 if (!s || !this.isUser(s.user)) return null;131 return { user: s.user, token: null, session: s, secret, refresh: this.id.touchSession(s) };132 }133134 who(req: http.IncomingMessage): Who | null {135 return this.byToken(presented(req)) ?? this.bySession(cookieValue(req.headers.cookie));136 }137138 // May who read the private repo slug (at dir)? A token for one repo reads that repo only.139 reads(who: Who | null, slug: string, dir: string): boolean {140 if (!who || !canRead(dir, who.user)) return false;141 return !who.token?.repo || who.token.repo === slug;142 }143}144145// Only Authorization: Bearer, for posts: a browser resends basic auth it146// has cached by itself, which would make it as ambient as a cookie.147export function bearer(req: http.IncomingMessage): string | null {148 return /^Bearer +(\S+)$/i.exec(req.headers.authorization ?? '')?.[1] ?? null;149}150151// Comments and approvals from review pages (site/assets/discuss.ts) are152// made by the forge's own command, the one ssh's git-shell-commands run:153// forge shell-cmd comment|approve, as the user, with the message on stdin.154// So the comment file, the approve line in the review log, the rules (who155// may approve, outdated revisions, the 32 KiB cap), the event and the156// rebuild are exactly what forge comment and forge approve get.157const FORGE_BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'forge');158export const POST_PATH = /^\/([^/]+)\/([^/]+)\/reviews\/([1-9][0-9]{0,8})\/(comment|approve)$/;159// Each user posts at most this many times a minute, and this many run at once.160const POSTS_PER_MINUTE = 20;161const RUNNING_MAX = 4;162// A comment is at most 32 KiB (sc_comment); escaped as JSON, a few times that.163const POST_MAX = 160 * 1024;164165export function shellCmd(cfg: PrivateConfig, user: string, args: string[], input: string): Promise<{ code: number | null; out: string; err: string }> {166 return forgeCmd(cfg, ['shell-cmd', ...args], input, { FORGE_USER: user });167}168169// bin/forge, for this forge, with input on stdin.170function forgeCmd(cfg: PrivateConfig, args: string[], input: string, extra: NodeJS.ProcessEnv = {}): Promise<{ code: number | null; out: string; err: string }> {171 return new Promise((ok, no) => {172 // FORGE_VIA: refusals worded for a page (reload it), not for a checkout.173 const env: NodeJS.ProcessEnv = { ...process.env, FORGE_ROOT: cfg.root, FORGE_VIA: 'web', ...extra };174 if (cfg.baseUrl) env.FORGE_BASE_URL = cfg.baseUrl;175 const p = spawn(FORGE_BIN, args, { cwd: cfg.root, env, stdio: ['pipe', 'pipe', 'pipe'], timeout: 5 * 60 * 1000 });176 const out: Buffer[] = [];177 const err: Buffer[] = [];178 p.stdout.on('data', (d: Buffer) => out.push(d));179 p.stderr.on('data', (d: Buffer) => err.push(d));180 p.on('error', no);181 p.on('close', (code) => ok({ code, out: Buffer.concat(out).toString('utf8'), err: Buffer.concat(err).toString('utf8') }));182 p.stdin.on('error', () => {});183 p.stdin.end(input);184 });185}186187// What bin/forge said it refused: its "forge: " lines.188const refusal = (err: string, otherwise: string): string => err.split('\n').filter((l) => l.startsWith('forge: ')).map((l) => l.slice(7)).join('\n') || otherwise;189190// Invites: each address may post this many times a minute with a live191// invite's code, and one accept runs at a time, since each writes192// .forge/keys. A dead code (the codes can't be guessed) costs a lookup and193// gets the same answer whatever was wrong, so it isn't counted: nobody can194// use up an invitee's turns by posting junk.195const INVITE_POSTS_PER_MINUTE = 10;196// Sign-in options: each address may ask this many times a minute.197const OPTIONS_PER_MINUTE = 20;198199// The sign-in and list pages: no framing, scripts and styles of our own only.200const PAGE_HEADERS = {201 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'private, no-store', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'same-origin',202 'Content-Security-Policy': "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",203};204205// Where to go after signing in: a path on this site.206const safeNext = (s: string | null): string => (s && /^\/(?![/\\])/.test(s) && !/[\u0000-\u001f]/.test(s) ? s : '');207208// The server's requests, for servePrivate and demo/serve.ts.209export function privateHandler(cfg: PrivateConfig): http.RequestListener {210 const gate = new Gate(cfg);211 const rp = relyingParty(cfg.baseUrl, cfg.name ?? 'forge');212 const login = rp ? new Login(rp, () => gate.id, (u) => gate.isUser(u)) : null;213 let renderer: Render | null = null;214 const render = (template: string, data: Record<string, unknown>): string => {215 renderer ??= pageRenderer();216 const forge = { config: { ...forgeConfig({ ...process.env, FORGE_ROOT: cfg.root }), name: cfg.name ?? 'forge', baseUrl: cfg.baseUrl, ssh: cfg.ssh ?? 'git@localhost' } };217 return renderer(template, { forge, ...data });218 };219 const cookie = (who: Who | null): Record<string, string> => (who?.refresh && who.secret ? { 'Set-Cookie': sessionCookie(who.secret, SESSION_TTL) } : {});220 const signalJson = (user: string): string => (login ? JSON.stringify(login.signal(user)) : '');221222 function page(req: http.IncomingMessage, res: http.ServerResponse, url: URL): void {223 const head = req.method === 'HEAD';224 const m = /^\/([^/]+)\/([^/]+)(?:\/|$)/.exec(url.pathname);225 if (!m || (req.method !== 'GET' && !head)) return notFound(res, head);226 // Who is asking is worked out before anything about the repo, so the227 // work (and time) of a 404 doesn't depend on whether the repo is there.228 const who = gate.who(req);229 let slug: string;230 try {231 slug = `${decodeURIComponent(m[1])}/${decodeURIComponent(m[2])}`;232 } catch {233 return notFound(res, head);234 }235 const dir = repoDir(cfg.root, slug);236 if (!dir || isPublic(dir) || !gate.reads(who, slug, dir)) return notFound(res, head);237 const file = resolve(cfg.priv, url.pathname);238 if (!file) return notFound(res, head);239 if (file === 'redirect') {240 res.writeHead(308, { Location: url.pathname + '/' + url.search, ...cookie(who) }).end();241 return;242 }243 // Release downloads as Caddy sends a public repo's: attachments, never244 // sniffed, in byte ranges if asked.245 sendFile(req, res, file, { 'Cache-Control': 'private, no-cache', ...(DOWNLOAD.test(url.pathname) ? DOWNLOAD_HEADERS : {}), ...cookie(who) });246 }247248 // An HTTPS clone or fetch of a repo without git-daemon-export-ok, asked by249 // Caddy (forward_auth) before git http-backend serves it. X-Forge-Repo is250 // the owner/repo Caddy matched in the path it will serve. git sends its251 // credentials only when asked, so without a token the answer is 401, for252 // any repo; with a live token, 200 if its holder may read the repo and253 // 404 otherwise, the same for a private repo and a missing one. Only254 // tokens count here: git has no cookies.255 function gitAuth(req: http.IncomingMessage, res: http.ServerResponse): void {256 const who = gate.byToken(presented(req));257 const slug = String(req.headers['x-forge-repo'] ?? '');258 if (!who) {259 res.writeHead(401, { 'WWW-Authenticate': `Basic realm="${cfg.name ?? 'forge'}", charset="UTF-8"`, 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache' });260 res.end('A token is needed: ssh <host> token --repo <owner>/<repo>, then use it as the password.\n');261 return;262 }263 const dir = repoDir(cfg.root, slug);264 if (!dir || !(isPublic(dir) || gate.reads(who, slug, dir))) {265 res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache' }).end('Repository not found\n');266 return;267 }268 res.writeHead(200, { 'Cache-Control': 'no-cache' }).end();269 }270271 // The forge's assets by hashed name, and the CSS bundles of the pages272 // rendered here, in case a build hasn't put them in www yet.273 function asset(req: http.IncomingMessage, res: http.ServerResponse, url: URL): void {274 const name = url.pathname.slice('/assets/'.length);275 const a = Object.values(assets()).find((x) => x.file === name);276 const css = renderer?.bundles.get(url.pathname);277 const data = a?.data ?? (css != null ? Buffer.from(css) : null);278 if (!data) return notFound(res, req.method === 'HEAD');279 const type = /\.css$/.test(name) ? 'text/css; charset=utf-8' : /\.js$/.test(name) ? 'text/javascript; charset=utf-8' : /\.wasm$/.test(name) ? 'application/wasm' : /\.svg$/.test(name) ? 'image/svg+xml' : /\.webp$/.test(name) ? 'image/webp' : /\.woff2$/.test(name) ? 'font/woff2' : 'application/octet-stream';280 res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'public, max-age=31536000, immutable', 'Content-Length': data.length });281 res.end(req.method === 'HEAD' ? undefined : data);282 }283284 function loginPage(req: http.IncomingMessage, res: http.ServerResponse, url: URL): void {285 const who = gate.bySession(cookieValue(req.headers.cookie));286 const html = render('login.njk', {287 p: { title: `Sign in - ${cfg.name ?? 'forge'}` }, user: who?.user ?? '', csrf: who?.secret ? csrfToken(who.secret) : '',288 signal: who ? signalJson(who.user) : '', next: safeNext(url.searchParams.get('next')), ready: !!login,289 });290 res.writeHead(200, { ...PAGE_HEADERS, ...cookie(who) }).end(html);291 }292293 function listPage(req: http.IncomingMessage, res: http.ServerResponse): void {294 const who = gate.bySession(cookieValue(req.headers.cookie));295 if (!who) {296 res.writeHead(303, { Location: '/login/?next=/private/', 'Cache-Control': 'no-store' }).end();297 return;298 }299 const fcfg = forgeConfig({ ...process.env, FORGE_ROOT: cfg.root });300 const repos = readable(cfg.root, who.user).map((slug) => {301 const [owner, name] = slug.split('/');302 const s = summarize(fcfg, owner, name);303 return { owner, name, description: s.description, lastCommit: s.lastCommit, href: s.urls.home.href, built: fs.existsSync(path.join(cfg.priv, owner, name, 'index.html')) };304 });305 const html = render('private.njk', { p: { title: `Private repos - ${cfg.name ?? 'forge'}` }, user: who.user, csrf: csrfToken(who.secret!), signal: signalJson(who.user), repos });306 res.writeHead(200, { ...PAGE_HEADERS, ...cookie(who) }).end(html);307 }308309 const json = (res: http.ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}): void => {310 res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers }).end(JSON.stringify(body));311 };312313 // A signed-in session, as the cookie that carries it.314 function signedIn(req: http.IncomingMessage, user: string, passkey: string): Record<string, string> {315 const { secret } = gate.id.createSession(user, passkey, agentName(req.headers['user-agent']));316 return { 'Set-Cookie': sessionCookie(secret, SESSION_TTL) };317 }318319 // Who is signed in, for the script of static review pages, which shows320 // the comment form and Approve button to someone signed in, with the CSRF321 // token their posts carry. Never kept by any cache.322 function whoami(req: http.IncomingMessage, res: http.ServerResponse): void {323 const who = gate.bySession(cookieValue(req.headers.cookie));324 const body = who ? { user: who.user, csrf: csrfToken(who.secret!) } : { user: null };325 json(res, 200, body, { 'Cache-Control': 'private, no-store', Vary: 'Cookie', ...cookie(who) });326 }327328 // A comment or approval. A private repo you can't read, signed in or329 // not, is the 404 a missing repo gets, as for its pages. Then it takes a330 // user, signed in or with a Bearer token (which may be for this repo331 // only), and for a session, a post from the forge's own pages (Origin)332 // that carries the session's CSRF token. The fields come as a form or333 // JSON: body (markdown), at (the tip the page showed, so a comment334 // belongs to the revision its writer saw and an approval of a review that335 // has moved on is refused), line (path:n, on the new side), csrf. The336 // answer is JSON, { ok, message, comment } or { error }, in the forge's337 // own words.338 const recent = new Map<string, number[]>();339 let running = 0;340 async function discuss(req: http.IncomingMessage, res: http.ServerResponse, m: RegExpExecArray): Promise<void> {341 const who = gate.byToken(bearer(req)) ?? gate.bySession(cookieValue(req.headers.cookie));342 let slug: string;343 try {344 slug = `${decodeURIComponent(m[1])}/${decodeURIComponent(m[2])}`;345 } catch {346 return notFound(res);347 }348 const dir = repoDir(cfg.root, slug);349 if (!dir || !(isPublic(dir) || gate.reads(who, slug, dir))) return notFound(res);350 const what = m[4] === 'comment' ? 'comment' : 'approve';351 if (!who) return json(res, 401, { error: `Sign in to ${what}` });352 if (who.token?.repo && who.token.repo !== slug) return json(res, 403, { error: `This token is for ${who.token.repo} only` });353 if (who.session && (!login || req.headers.origin !== login.rp.origin)) return json(res, 403, { error: 'not from this forge' });354 let raw: string;355 try {356 if (Number(req.headers['content-length'] ?? 0) > POST_MAX) throw new Error('too large');357 raw = await readBody(req, POST_MAX);358 } catch {359 return json(res, 413, { error: `${what}: longer than 32 KiB` });360 }361 let form: Record<string, unknown>;362 if (/^application\/json\b/i.test(req.headers['content-type'] ?? '')) {363 try {364 form = JSON.parse(raw || '{}');365 } catch {366 return json(res, 400, { error: 'not JSON' });367 }368 if (!form || typeof form !== 'object' || Array.isArray(form)) return json(res, 400, { error: 'not a JSON object' });369 } else form = Object.fromEntries(new URLSearchParams(raw));370 const field = (k: string): string => (form[k] == null ? '' : String(form[k]));371 if (who.session && !sameSecret(field('csrf'), csrfToken(who.secret!))) return json(res, 403, { error: 'This page is out of date: reload it and try again' });372 const at = field('at');373 if (at && !/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(at)) return json(res, 400, { error: `${what}: at is not a commit id` });374 const t = Date.now();375 const mine = (recent.get(who.user) ?? []).filter((x) => t - x < 60_000);376 if (mine.length >= POSTS_PER_MINUTE) return json(res, 429, { error: 'That is a lot at once: wait a minute' }, { 'Retry-After': '60' });377 if (running >= RUNNING_MAX) return json(res, 503, { error: 'The forge is busy: try again in a moment' }, { 'Retry-After': '5' });378 mine.push(t);379 recent.set(who.user, mine);380 const args = [what, slug, m[3]];381 if (at) args.push('--at', at);382 let input = '';383 if (what === 'comment') {384 if (field('line')) args.push('--line', field('line'));385 // Lines of text, as forge comment -m sends it.386 input = field('body');387 if (input && !input.endsWith('\n')) input += '\n';388 }389 running++;390 let r;391 try {392 r = await shellCmd(cfg, who.user, args, input);393 } finally {394 running--;395 }396 if (r.code !== 0) {397 const said = refusal(r.err, `${what} failed`);398 return json(res, /^no (review|repo) /.test(said) ? 404 : 400, { error: said }, cookie(who));399 }400 const message = r.out.trim();401 const n = /^comment (\d+) /.exec(message)?.[1];402 json(res, 200, { ok: true, message, ...(n ? { comment: Number(n) } : {}) }, cookie(who));403 }404405 // An invite's link (/login/#invite=<code>): options says whose name it406 // holds; accept takes an SSH public key, which bin/forge user accept407 // checks and adds the way forge user add does, using the invite up, and408 // answers with a code for the passkey enrollment that follows on the409 // same page (the one ssh passkey would print). Nobody is signed in by it.410 // The code is checked first: without a live invite every post gets the411 // same answer, whatever its key, so nothing is learned about keys.412 const invitePosts = new Window(INVITE_POSTS_PER_MINUTE, 60_000);413 let accepting = false;414 async function invite(req: http.IncomingMessage, res: http.ServerResponse, what: 'options' | 'accept', posted: unknown): Promise<void> {415 const body = (posted && typeof posted === 'object' ? posted : {}) as Record<string, unknown>;416 const code = typeof body.code === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(body.code) ? body.code : '';417 const i = code ? gate.id.invitation(code) : null;418 if (!i) return json(res, 400, { error: INVITE_EXPIRED, expired: true });419 if (!invitePosts.take(clientIp(req))) return json(res, 429, { error: 'That is a lot at once: wait a minute' }, { 'Retry-After': '60' });420 if (what === 'options') return json(res, 200, { user: i.user, repo: i.repo, role: i.role });421 const key = typeof body.key === 'string' ? body.key.trim() : '';422 if (!key || key.length > 8192 || /[\r\n]/.test(key)) return json(res, 400, { error: 'Paste one SSH public key: the one line of your .pub file' });423 if (accepting) return json(res, 503, { error: 'The forge is busy: try again in a moment' }, { 'Retry-After': '5' });424 accepting = true;425 let r;426 try {427 r = await forgeCmd(cfg, ['user', 'accept'], `${code}\n${key}\n`);428 } finally {429 accepting = false;430 }431 const user = r.out.trim().split('\n').pop() ?? '';432 if (r.code !== 0 || !user) {433 const said = refusal(r.err, 'The invite was not taken up');434 return json(res, 400, { error: said, ...(said === INVITE_EXPIRED ? { expired: true } : {}) });435 }436 json(res, 200, { user, enroll: gate.id.enroll(user, '').code });437 }438439 const optionsAsked = new Window(OPTIONS_PER_MINUTE, 60_000);440 async function post(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void> {441 // Only the forge's own pages may post here (and sign anyone in or out).442 if (!login || req.headers.origin !== login.rp.origin) return json(res, 403, { error: 'not from this forge' });443 const raw = await readBody(req);444 if (url.pathname === '/login/logout') {445 const form = new URLSearchParams(raw);446 const secret = cookieValue(req.headers.cookie);447 if (secret && sameSecret(form.get('csrf') ?? '', csrfToken(secret))) gate.id.endSession(secret);448 else if (secret && gate.id.session(secret)) return json(res, 403, { error: 'stale form; reload the page and sign out again' });449 res.writeHead(303, { Location: '/login/', 'Set-Cookie': sessionCookie('', 0), 'Cache-Control': 'no-store' }).end();450 return;451 }452 let body: Record<string, unknown>;453 try {454 body = JSON.parse(raw || '{}');455 } catch {456 return json(res, 400, { error: 'not JSON' });457 }458 // Options are tied to this browser's challenge cookie (lib/login.ts),459 // which it gets with them, and each address asks only so often.460 const browser = browserId(req.headers.cookie);461 const options = async (make: (b: string) => Promise<unknown>): Promise<void> => {462 if (!optionsAsked.take(clientIp(req))) return json(res, 429, { error: 'That is a lot at once: wait a minute' }, { 'Retry-After': '60' });463 const b = browser ?? newBrowserId();464 json(res, 200, await make(b), { 'Set-Cookie': challengeCookie(b) });465 };466 try {467 if (url.pathname === '/login/options') return await options((b) => login.loginOptions(b));468 if (url.pathname === '/login/verify') {469 const { user, passkey } = await login.loginVerify(body, browser);470 return json(res, 200, { user, signal: login.signal(user) }, signedIn(req, user, passkey.id));471 }472 if (url.pathname === '/login/invite/options') return await invite(req, res, 'options', body);473 if (url.pathname === '/login/invite/accept') return await invite(req, res, 'accept', body);474 if (url.pathname === '/login/enroll/options') return await options((b) => login.enrollOptions(body.code, b));475 if (url.pathname === '/login/enroll/verify') {476 const { user, passkey } = await login.enrollVerify(body.code, body.response, agentName(req.headers['user-agent']), browser);477 return json(res, 200, { user, signal: login.signal(user) }, signedIn(req, user, passkey.id));478 }479 } catch (e) {480 if (e instanceof LoginError) return json(res, 400, { error: e.message, ...e.data });481 throw e;482 }483 notFound(res);484 }485486 return (req, res) => {487 const fail = (e: unknown): void => {488 console.error(e);489 if (!res.headersSent) res.writeHead(500).end();490 else res.destroy();491 };492 try {493 const url = new URL(req.url ?? '/', 'http://x');494 const get = req.method === 'GET' || req.method === 'HEAD';495 const write = req.method === 'POST' ? POST_PATH.exec(url.pathname) : null;496 if (write) discuss(req, res, write).catch(fail);497 else if (url.pathname === '/login/whoami' && get) whoami(req, res);498 else if (url.pathname.startsWith('/login/') && req.method === 'POST') post(req, res, url).catch(fail);499 else if (url.pathname === '/login/' && get) loginPage(req, res, url);500 else if (url.pathname === '/private/' && get) listPage(req, res);501 else if (url.pathname === '/login' || url.pathname === '/private') res.writeHead(308, { Location: url.pathname + '/' + url.search }).end();502 else if (url.pathname.startsWith('/assets/') && get) asset(req, res, url);503 else if (url.pathname === '/auth/git' && get) gitAuth(req, res);504 else page(req, res, url);505 } catch (e) {506 fail(e);507 }508 };509}510511export function servePrivate(cfg: PrivateConfig, port: number): http.Server {512 return timeouts(http.createServer(privateHandler(cfg))).listen(port, '127.0.0.1', () => console.log(`private pages on 127.0.0.1:${port}, from ${cfg.priv}`));513}514515// Run directly: node lib/private-server.ts516if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {517 servePrivate(privateConfig(), Number(process.env.FORGE_PRIVATE_PORT || 8093));518}