syntaxai/tdd.md · main · src / d21_handlers_repo_browse.ts

d21_handlers_repo_browse.ts 128 lines · 4656 bytes raw
// 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",
    },
  });
};