/** * The request router for the browse extension. * * A request is matched to a route, every path goes through `paths.ts`, and the * filesystem reads are capped by config. The function returns a plain * `ServeResponse` (or a file descriptor the server streams), so it can be * tested without a socket. */ import { stat } from "node:fs/promises"; import { THEME_JS } from "../../lib/web/index.ts"; import { CLIENT_JS } from "./client.ts"; import type { ServeConfig } from "./config.ts"; import { changeDirs, isDiffMode, type DiffMode } from "./diff.ts"; import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts"; import { buildFileIndex, buildTree, contentTypeFor, isImageName, listDirectory, readFileView, type DirEntry, type FileIndexProvider, type TreeNode, } from "./files.ts"; import { FOLDER_ICON, iconHref, languageOf } from "./icons.ts"; import { fuzzySearch } from "./fuzzy.ts"; import { renderDirectoryPage, renderDiffPage, renderErrorPage, renderFileDiffPage, renderFilePage } from "./html.ts"; import type { ChangeSummary } from "./html.ts"; import { encodePath, HttpError, relativePath, resolveDecodedPath, resolveRequestPath } from "./paths.ts"; export interface ServeContext { root: string; config: ServeConfig; /** Read-only git context for the page header; omitted when unavailable. */ git?: GitStatusProvider; /** Read-only diff provider; omitted when the diff view is unavailable. */ diff?: GitDiffProvider; /** Cached quick-open file index; built on demand when omitted. */ index?: FileIndexProvider; } export interface ServeResponse { status: number; headers: Record; body?: string | Buffer; /** When set, the server streams this file instead of `body`. */ filePath?: string; } const HOST_PATTERN = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i; /** Most palette rows the client will render for one query. */ const RESULT_LIMIT = 50; const HTML_SECURITY: Record = { "Content-Type": "text/html; charset=utf-8", "Content-Security-Policy": "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'self'; connect-src 'self'", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", "Cache-Control": "no-store", }; const RAW_SECURITY: Record = { "Content-Security-Policy": "default-src 'none'; script-src 'none'; sandbox", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", "Cache-Control": "no-store", }; function htmlResponse(status: number, body: string): ServeResponse { return { status, headers: { ...HTML_SECURITY }, body }; } function jsonResponse(status: number, value: unknown): ServeResponse { return { status, headers: { "Content-Type": "application/json; charset=utf-8", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", "Cache-Control": "no-store", }, body: JSON.stringify(value), }; } async function treeFor(ctx: ServeContext, _currentRel: string): Promise { return buildTree(ctx.root, _currentRel, ctx.config.maxDirEntries); } /** Resolve the header's git context, never failing the request. */ async function gitFor(ctx: ServeContext): Promise { if (!ctx.git) return undefined; try { return await ctx.git(); } catch { return undefined; } } /** The change list for a mode, or `undefined` outside a repository. */ async function diffFor(ctx: ServeContext, mode: DiffMode) { if (!ctx.config.gitDiff || !ctx.diff) return undefined; try { return await ctx.diff.list(mode); } catch { return undefined; } } /** The marker/totals index derived from a change list. */ function summaryOf(list: DiffList): ChangeSummary { return { count: list.files.length, insertions: list.insertions, deletions: list.deletions, files: new Set(list.files.map((file) => file.rel)), dirs: changeDirs(list.files), }; } /** The `head` change summary, for the header chip, tree markers, and file action. */ async function changed(ctx: ServeContext): Promise { const list = await diffFor(ctx, "head"); return list ? summaryOf(list) : undefined; } async function errorResponse( ctx: ServeContext, status: number, message: string, extra?: Record, ): Promise { let tree: TreeNode = { name: ctx.root, rel: "", isDir: true, isImage: false, broken: false, kind: "directory", children: [], }; try { tree = await treeFor(ctx, ""); } catch { // Keep the minimal tree. } const body = renderErrorPage({ rootLabel: ctx.root, status, message, tree, rel: "", git: await gitFor(ctx), }); return { status, headers: { ...HTML_SECURITY, ...extra }, body }; } async function browse(ctx: ServeContext, rawRel: string): Promise { const abs = await resolveRequestPath(ctx.root, rawRel); const rel = relativePath(ctx.root, abs); const info = await stat(abs).catch(() => undefined); if (!info || !info.isDirectory()) return view(ctx, rawRel, false); const listing = await listDirectory(ctx.root, rel, ctx.config.maxDirEntries); const tree = await treeFor(ctx, rel); const git = await gitFor(ctx); const changes = await changed(ctx); return htmlResponse( 200, renderDirectoryPage({ rootLabel: ctx.root, rel, listing, tree, thumbnails: ctx.config.thumbnails, maxThumbBytes: ctx.config.maxThumbBytes, git, summary: changes, }), ); } async function view(ctx: ServeContext, rawRel: string, raw: boolean): Promise { const abs = await resolveRequestPath(ctx.root, rawRel); const rel = relativePath(ctx.root, abs); const info = await stat(abs).catch(() => undefined); if (info?.isDirectory()) return browse(ctx, rawRel); if (!info) throw new HttpError(404, "That path does not exist."); const file = await readFileView(ctx.root, rel, ctx.config.maxFileBytes, ctx.config.maxTextLines, ctx.config.markdown); const tree = await treeFor(ctx, rel); const git = await gitFor(ctx); const changes = await changed(ctx); return htmlResponse( 200, renderFilePage({ rootLabel: ctx.root, rel, file, tree, maxFileBytes: ctx.config.maxFileBytes, git, markdownRaw: raw, changed: changes?.files.has(rel), summary: changes, }), ); } /** The change overview page for a mode. A non-repository is an empty state, not an error. */ async function diffOverview(ctx: ServeContext, mode: DiffMode): Promise { if (!ctx.config.gitDiff || !ctx.diff) throw new HttpError(404, "The diff view is not available."); const list = await diffFor(ctx, mode); const tree = await treeFor(ctx, ""); const git = await gitFor(ctx); return htmlResponse( 200, renderDiffPage({ rootLabel: ctx.root, mode, files: list?.files ?? [], stat: list?.stat ?? "", tree, git, summary: list ? summaryOf(list) : undefined, }), ); } /** A single file's unified diff for a mode. */ async function diffFile(ctx: ServeContext, rawRel: string, mode: DiffMode): Promise { if (!ctx.config.gitDiff || !ctx.diff) throw new HttpError(404, "The diff view is not available."); const abs = await resolveRequestPath(ctx.root, rawRel); const rel = relativePath(ctx.root, abs); const info = await stat(abs).catch(() => undefined); if (info?.isDirectory()) throw new HttpError(400, "That path is a folder."); let diff: Awaited>; try { diff = await ctx.diff.file(rel, mode); } catch { diff = undefined; } if (!diff) throw new HttpError(404, "No diff is available for that file."); const tree = await treeFor(ctx, rel); const git = await gitFor(ctx); const changes = await changed(ctx); return htmlResponse( 200, renderFileDiffPage({ rootLabel: ctx.root, rel, mode, diff, tree, git, summary: changes, }), ); } function disposition(rel: string, inline: boolean): string { const name = rel.slice(rel.lastIndexOf("/") + 1).replace(/["\\\r\n]/g, "_"); const encoded = encodeURIComponent(rel.slice(rel.lastIndexOf("/") + 1)); return `${inline ? "inline" : "attachment"}; filename="${name}"; filename*=UTF-8''${encoded}`; } async function raw(ctx: ServeContext, rawRel: string): Promise { const abs = await resolveRequestPath(ctx.root, rawRel); const rel = relativePath(ctx.root, abs); const info = await stat(abs).catch(() => undefined); if (!info || info.isDirectory()) throw new HttpError(404, "That file does not exist."); const inline = isImageName(rel) && !/\.svg$/i.test(rel); return { status: 200, headers: { ...RAW_SECURITY, "Content-Type": contentTypeFor(rel), "Content-Length": String(info.size), "Content-Disposition": disposition(rel, inline), }, filePath: abs, }; } function treeApiEntry(entry: DirEntry): Record { const spec = entry.isDir ? FOLDER_ICON : languageOf(entry.name); return { name: entry.name, path: entry.rel, href: entry.isDir ? `/browse/${encodePath(entry.rel)}` : `/view/${encodePath(entry.rel)}`, isDir: entry.isDir, broken: entry.broken, iconHref: iconHref(spec.icon), colorClass: spec.colorClass, label: spec.label, }; } async function treeApi(ctx: ServeContext, decodedPath: string): Promise { const abs = await resolveDecodedPath(ctx.root, decodedPath); const rel = relativePath(ctx.root, abs); const info = await stat(abs).catch(() => undefined); if (info && !info.isDirectory()) throw new HttpError(400, "That path is not a folder."); const listing = await listDirectory(ctx.root, rel, ctx.config.maxDirEntries); return jsonResponse(200, { path: rel, entries: listing.entries.map(treeApiEntry) }); } /** Fuzzy file search for the quick-open palette, over the cached index. */ async function filesApi(ctx: ServeContext, query: string): Promise { const index = ctx.index ? await ctx.index() : await buildFileIndex(ctx.root, ctx.config.maxIndexEntries); const results = fuzzySearch(query, index.files, RESULT_LIMIT); return jsonResponse(200, { query, total: index.files.length, truncated: index.truncated, results }); } /** * Handle one request. `method` and `headers` are the raw HTTP values; `rawUrl` * is the request target (path plus optional query). */ export async function handleRequest( ctx: ServeContext, method: string, rawUrl: string, headers: Record = {}, ): Promise { const upper = method.toUpperCase(); if (upper !== "GET" && upper !== "HEAD") { return errorResponse(ctx, 405, "Only GET and HEAD are served.", { Allow: "GET, HEAD" }); } const host = headers.host; if (typeof host === "string" && host && !HOST_PATTERN.test(host)) { return errorResponse(ctx, 403, "Unexpected Host header."); } let pathname: string; let search: URLSearchParams; try { const url = new URL(rawUrl, "http://127.0.0.1"); pathname = url.pathname; search = url.searchParams; } catch { return errorResponse(ctx, 400, "Malformed request URL."); } try { if (pathname === "/app.js") { return { status: 200, headers: { ...HTML_SECURITY, "Content-Type": "text/javascript; charset=utf-8" }, body: CLIENT_JS, }; } if (pathname === "/theme.js") { return { status: 200, headers: { ...HTML_SECURITY, "Content-Type": "text/javascript; charset=utf-8" }, body: THEME_JS, }; } if (pathname === "/favicon.ico") return { status: 204, headers: { ...HTML_SECURITY }, body: "" }; if (pathname === "/api/tree") return await treeApi(ctx, search.get("path") ?? ""); if (pathname === "/api/files") return await filesApi(ctx, search.get("q") ?? ""); const rawMode = search.get("mode"); if (rawMode !== null && !isDiffMode(rawMode)) return errorResponse(ctx, 400, "Unknown diff mode."); const mode: DiffMode = isDiffMode(rawMode) ? rawMode : "head"; if (pathname === "/diff" || pathname === "/diff/") return await diffOverview(ctx, mode); if (pathname.startsWith("/diff/")) return await diffFile(ctx, pathname.slice("/diff/".length), mode); if (pathname === "/" || pathname === "/browse" || pathname === "/browse/") return await browse(ctx, ""); if (pathname.startsWith("/browse/")) return await browse(ctx, pathname.slice("/browse/".length)); if (pathname.startsWith("/view/")) return await view(ctx, pathname.slice("/view/".length), search.get("raw") === "1"); if (pathname.startsWith("/raw/")) return await raw(ctx, pathname.slice("/raw/".length)); return errorResponse(ctx, 404, "That page does not exist."); } catch (error) { if (error instanceof HttpError) return errorResponse(ctx, error.status, error.message); return errorResponse(ctx, 500, "Something went wrong reading that path."); } }