// c21 — handler: SAMA-native browsable repo at /GIT/. // GET /GIT/:owner/:repo/tree/:ref/ → directory listing // GET /GIT/:owner/:repo/blob/:ref/ → file viewer (md rendered) // GET /GIT/:owner/:repo/raw/:ref/ → raw file content // // Sits next to c21_handlers_commit_view (commit detail) — the two // together replace what visitors used to need git.tdd.md for. Reads // from the local bare repo via c14_git.lsTree / c14_git.readBlobAtRef. // // The owner/repo pair must match the locally-served bare repo // (syntaxai/tdd.md). Other pairs 404 — agent kata browse is not in // scope here. Path traversal is blocked by validating against // patterns that disallow ".." and absolute leading-slash inputs. import { renderNotFound, htmlResponse } from "./b51_render_layout.ts"; import { lsTree, readBlobAtRef } from "./c14_git.ts"; import { LIVE_REPO_OWNER, LIVE_REPO_NAME } from "./a31_site_config.ts"; import { renderRepoTree, renderRepoBlob } from "./b51_render_repo.ts"; const SAFE_OWNER_REPO = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; // Refs we accept as :ref. Branch names + full SHAs are common — // kept narrow on purpose (no slashes — branches like "feat/foo" // would clash with the wildcard path matching). const SAFE_REF = /^[A-Za-z0-9][A-Za-z0-9._-]{0,49}$/; // Single-tenant: the only allowed repo is LIVE_REPO_NAME. Owner is // implicit (LIVE_REPO_OWNER) and no longer carried in the URL. const isAllowedRepo = (repo: string): boolean => repo === LIVE_REPO_NAME && SAFE_OWNER_REPO.test(repo); // Only allow paths that look like ordinary repo entries — letters, // digits, hyphens, underscores, dots, slashes. Reject anything with // a ".." segment, leading or trailing slashes, or empty segments. const isSafePath = (p: string): boolean => { if (p === "") return true; // root if (p.startsWith("/") || p.endsWith("/")) return false; if (p.includes("//")) return false; if (!/^[A-Za-z0-9._\/-]+$/.test(p)) return false; for (const seg of p.split("/")) { if (seg === "" || seg === "." || seg === "..") return false; } return true; }; // Strip a leading "tree//" or "blob//" or "raw//" off // a captured pathname suffix, returning { kind, ref, path } or null. // Called from the fallback fetch in c21_app where the URL has been // matched only loosely. export interface RepoBrowseTarget { kind: "tree" | "blob" | "raw"; ref: string; path: string; } export const parseRepoBrowsePath = (suffix: string): RepoBrowseTarget | null => { // suffix is what comes after /GIT/// // e.g. "tree/main", "tree/main/content/blog", "blob/main/content/blog/foo.md" const m = /^(tree|blob|raw)\/([^/]+)(?:\/(.*))?$/.exec(suffix); if (!m) return null; const kind = m[1] as "tree" | "blob" | "raw"; const ref = m[2]!; const path = m[3] ?? ""; if (!SAFE_REF.test(ref)) return null; if (!isSafePath(path)) return null; return { kind, ref, path }; }; export const repoBrowseHandler = async ( req: Request, repo: string, target: RepoBrowseTarget, ): Promise => { const fullPath = `/GIT/${repo}/${target.kind}/${target.ref}${target.path ? "/" + target.path : ""}`; if (!isAllowedRepo(repo)) { const html = await renderNotFound(fullPath); return htmlResponse(html, 404); } if (target.kind === "tree") { const entries = await lsTree(target.ref, target.path); if (entries === null) { const html = await renderNotFound(fullPath); return htmlResponse(html, 404); } const html = await renderRepoTree({ owner: LIVE_REPO_OWNER, repo, ref: target.ref, path: target.path, entries, }); return htmlResponse(html); } if (target.kind === "blob") { const content = await readBlobAtRef(target.ref, target.path); if (content === null) { const html = await renderNotFound(fullPath); return htmlResponse(html, 404); } const html = await renderRepoBlob({ owner: LIVE_REPO_OWNER, repo, ref: target.ref, path: target.path, content, }); return htmlResponse(html); } // raw const content = await readBlobAtRef(target.ref, target.path); if (content === null) { const html = await renderNotFound(fullPath); return htmlResponse(html, 404); } // Markdown files served as text/plain so browsers render them // inline; everything else also text/plain (we don't try to detect // language types — c14_git already restricts to UTF-8). return new Response(content, { headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, max-age=60", }, }); };