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

d21_handlers_fallback.ts 214 lines · 8067 bytes raw
// c21 — handlers: the Bun.serve `fetch` fallback. Catches every request
// the routes table can't express directly: regex-matched multi-segment
// slugs (admin edit/delete, /p/<deep/slug>), the /GIT browse tree, the
// bare /<owner>/<repo>.git redirect, the git smart/dumb-HTTP proxy, and
// the bare /<owner>/<repo> repo view. Extracted from c21_app.ts per the
// SAMA Atomic rule.

import {
  renderNotFound,
  htmlResponse,
} from "./b51_render_layout.ts";
import { proxyToForgejo } from "./c14_forgejo.ts";
import { parseUrl } from "./c14_request_parse.ts";
import { getViewer } from "./b32_session.ts";
import { renderRepoView } from "./d21_handlers_repo_view.ts";
import {
  adminEditHandler,
  adminDeleteHandler,
} from "./d21_handlers_admin.ts";
import { renderPublicPage } from "./d21_handlers_content.ts";
import {
  parseRepoBrowsePath,
  repoBrowseHandler,
} from "./d21_handlers_repo_browse.ts";
import { rewriteOldGitUrl } from "./b32_git_url_redirect.ts";
import { rewriteOldSamaDisciplineUrl } from "./b32_sama_discipline_url_redirect.ts";
import { rewriteOldBlogUrl } from "./b32_blog_date_url_redirect.ts";

const isGitProtocol = (pathname: string, search: URLSearchParams): boolean => {
  if (pathname.includes(".git/") || pathname.endsWith(".git")) return true;
  if (
    pathname.endsWith("/info/refs") &&
    (search.get("service") === "git-upload-pack" || search.get("service") === "git-receive-pack")
  ) {
    return true;
  }
  if (pathname.endsWith("/git-upload-pack") || pathname.endsWith("/git-receive-pack")) {
    return true;
  }
  return false;
};

export const appFetch = async (req: Request): Promise<Response> => {
  const urlR = parseUrl(req.url);
  // Bun.serve guarantees req.url is well-formed for routed requests;
  // if parseUrl somehow fails, fall through to a 404 via the default
  // notFound branch at the end of this function.
  if (!urlR.ok) {
    const html = await renderNotFound("/");
    return htmlResponse(html, 404);
  }
  const url = urlR.value;

  // Static images under /images/<name>.<ext>. Convention: every new
  // site image lives at public/images/ and is served from /images/.
  // The whitelist of extensions + the strict filename pattern blocks
  // path traversal (no slashes after /images/, no leading dots).
  const imagesMatch = url.pathname.match(
    /^\/images\/([A-Za-z0-9][A-Za-z0-9._-]*)\.(svg|png|webp|jpg|jpeg|gif)$/,
  );
  if (imagesMatch) {
    const file = Bun.file(`./public/images/${imagesMatch[1]}.${imagesMatch[2]}`);
    if (await file.exists()) {
      const ext = imagesMatch[2]!;
      const contentType =
        ext === "svg" ? "image/svg+xml" :
        ext === "png" ? "image/png" :
        ext === "webp" ? "image/webp" :
        ext === "gif" ? "image/gif" :
        "image/jpeg";
      return new Response(file, {
        headers: {
          "Content-Type": contentType,
          "Cache-Control": "public, max-age=3600",
        },
      });
    }
  }

  // Admin edit/delete on multi-segment slugs (company/about, docs/spec/grammar
  // etc.). Bun's `:slug` param can't span "/" so anything with two-or-more
  // segments after the type slot ends up here. Single-segment is handled
  // by the routes table and never reaches this branch.
  const adminEditMulti = url.pathname.match(
    /^\/admin\/edit\/(page|post)\/([a-z0-9_\-/]+?)\/?$/,
  );
  if (adminEditMulti) {
    const reqP = Object.assign(req, {
      params: { type: adminEditMulti[1]!, slug: adminEditMulti[2]! },
    });
    return adminEditHandler(reqP);
  }
  const adminDeleteMulti = url.pathname.match(
    /^\/admin\/delete\/(page|post)\/([a-z0-9_\-/]+?)\/?$/,
  );
  if (adminDeleteMulti) {
    const reqP = Object.assign(req, {
      params: { type: adminDeleteMulti[1]!, slug: adminDeleteMulti[2]! },
    });
    return adminDeleteHandler(reqP);
  }

  // Public sxdoc-backed pages on multi-segment slugs (e.g.
  // /p/company/about, /p/docs/spec/grammar). Single-segment goes through
  // the explicit `/p/:slug` route on Bun.serve.
  const publicPageMulti = url.pathname.match(/^\/p\/([a-z0-9_\-/]+?)\/?$/);
  if (publicPageMulti) {
    return renderPublicPage(publicPageMulti[1]!);
  }

  // Bare /<owner>/<repo>.git (no sub-path) is what someone gets when
  // they paste the clone URL into a browser. Without intervention our
  // proxy hands it to Forgejo, whose chrome then leaks onto tdd.md.
  // Redirect to the clean URL so the visitor lands on the Bun-native
  // scoreboard. Real git operations always have sub-paths
  // (/info/refs, /git-upload-pack, /objects/...) and continue to be
  // proxied below.
  const bareGitUrl = url.pathname.match(
    /^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\.git\/?$/,
  );
  if (bareGitUrl) {
    return new Response(null, {
      status: 302,
      headers: { Location: `/${bareGitUrl[1]}/${bareGitUrl[2]}` },
    });
  }

  // Legacy /GIT/syntaxai/tdd.md/<suffix> URLs permanent-redirect to
  // the new owner-less shape. MUST sit before the browse-match below
  // so the legacy URL never reaches the browse handler. One regex
  // covers every kind (tree/blob/raw/commit) + every future path.
  const newGitPath = rewriteOldGitUrl(url.pathname);
  if (newGitPath !== null) {
    return new Response(null, {
      status: 301,
      headers: {
        Location: newGitPath,
        "Cache-Control": "public, max-age=86400",
      },
    });
  }

  // Legacy /sama/<discipline> URLs permanent-redirect to the new
  // /sama/discipline/<slug> namespace. Same pattern-as-redirect shape
  // as the /GIT/ block above — pure Layer-1 transform + Layer-3
  // Response wrapper. Hypothesis-test instance of the pattern's
  // reusability (see /blog/2026-05/sama-v2-git-url-refactor-postmortem).
  const newSamaPath = rewriteOldSamaDisciplineUrl(url.pathname);
  if (newSamaPath !== null) {
    return new Response(null, {
      status: 301,
      headers: {
        Location: newSamaPath,
        "Cache-Control": "public, max-age=86400",
      },
    });
  }

  // Legacy /blog/<slug> URLs permanent-redirect to /blog/<yyyy-mm>/<slug>.
  // Third instance of the pattern, first DATA-DRIVEN one — helper looks
  // up the post's date in ALL_POSTS to build the new prefix.
  const newBlogPath = rewriteOldBlogUrl(url.pathname);
  if (newBlogPath !== null) {
    return new Response(null, {
      status: 301,
      headers: {
        Location: newBlogPath,
        "Cache-Control": "public, max-age=86400",
      },
    });
  }

  // SAMA-native repo browse at /GIT/:repo/{tree,blob,raw}/:ref/<path>.
  // The wildcard path needs more flexibility than Bun's :param routes
  // give us (no slashes), so we match in the fallback fetch instead.
  const gitBrowseMatch = url.pathname.match(
    /^\/GIT\/([A-Za-z0-9][A-Za-z0-9._-]+)\/(.+)$/,
  );
  if (gitBrowseMatch) {
    const repo = gitBrowseMatch[1]!;
    const suffix = gitBrowseMatch[2]!;
    // Skip the commit/<sha> shape — that's c21_handlers_commit_view's
    // turf and lives as an explicit Bun.serve route in c21_app.
    if (!suffix.startsWith("commit/")) {
      const target = parseRepoBrowsePath(suffix);
      if (target !== null) {
        return repoBrowseHandler(req, repo, target);
      }
    }
  }

  // Git smart-HTTP and dumb-HTTP — proxy raw to Forgejo.
  if (isGitProtocol(url.pathname, url.searchParams)) {
    return proxyToForgejo(req, url.pathname + url.search);
  }

  // Bare repo URL: /<owner>/<repo> — render Bun-native view via Forgejo API.
  // Two segments only, no trailing path. Reserved top-level paths are
  // already matched by explicit routes in c21_app and never reach here.
  const repoMatch = url.pathname.match(/^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/?$/);
  if (repoMatch) {
    const viewer = await getViewer(req);
    return renderRepoView(repoMatch[1]!, repoMatch[2]!, viewer);
  }

  const html = await renderNotFound(url.pathname);
  return htmlResponse(html, 404);
};

export const appError = (err: Error): Response => {
  console.error(err);
  return new Response("internal error", { status: 500 });
};