syntaxai/tdd.md · main · src / d21_handlers_repo_browse.ts
// c21 — handler: SAMA-native browsable repo at /GIT/.
// GET /GIT/:owner/:repo/tree/:ref/<path> → directory listing
// GET /GIT/:owner/:repo/blob/:ref/<path> → file viewer (md rendered)
// GET /GIT/:owner/:repo/raw/:ref/<path> → 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/<ref>/" or "blob/<ref>/" or "raw/<ref>/" 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/<owner>/<repo>/
// 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<Response> => {
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",
},
});
};