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

d21_handlers_edit.ts 121 lines · 4540 bytes raw
// c21 — handlers: the self-hosted editor. Admin-only flow:
// GET → form (login wall + non-admin wall as gates), POST → write
// commit straight to the local bare git repo via c14_git, then mirror
// to the container's content/ filesystem so the live page reflects it.
// Forgejo no longer participates in tdd.md's own repo lifecycle.

import { renderNotFound, htmlResponse } from "./b51_render_layout.ts";
import { getViewer } from "./b32_session.ts";
import { resolveEdit, type ResolvedEdit } from "./b32_edit_resolve.ts";
import {
  validateEditBody,
  isNoOpEdit,
  EditValidationError,
} from "./a31_edit_validation.ts";
import { ADMIN_USERNAME } from "./a31_site_config.ts";
import {
  commitFile,
  getFileBlobSha,
  type GitCommitOutcome,
} from "./c14_git.ts";
import { buildCommitMessage, noreplyEmail } from "./a31_commit_meta.ts";
import {
  renderEditFormPage,
  renderEditLoginWall,
  renderEditNonAdminWall,
  renderEditAppliedLive,
  renderEditCommitFailed,
} from "./b51_render_edit.ts";

const readCurrentBody = async (filePath: string): Promise<string | null> => {
  const file = Bun.file(`./${filePath}`);
  if (!(await file.exists())) return null;
  return await file.text();
};

// Mirror the Forgejo write to the container's local filesystem so the
// next page render reflects the change without waiting for the next
// deploy. The deploy script's git-pull-from-Forgejo restores the same
// bytes on container restart.
const applyLiveEdit = async (resolved: ResolvedEdit, body: string): Promise<void> => {
  await Bun.write(`./${resolved.filePath}`, body);
};

// GET + POST /edit/:section/:slug — single handler, branches on method.
export const editPageHandler = async (req: Request & { params: { section: string; slug: string } }): Promise<Response> => {
  const resolved = resolveEdit(req.params.section, req.params.slug);
  if (!resolved) {
    const html = await renderNotFound(`/edit/${req.params.section}/${req.params.slug}`);
    return htmlResponse(html, 404);
  }

  const viewer = await getViewer(req);
  if (!viewer) {
    const html = await renderEditLoginWall(resolved);
    return htmlResponse(html, 401);
  }

  if (viewer !== ADMIN_USERNAME) {
    const html = await renderEditNonAdminWall(resolved, viewer);
    return htmlResponse(html, 403);
  }

  if (req.method === "POST") {
    const form = await req.formData();
    let body: string;
    try {
      body = validateEditBody(form.get("body"));
    } catch (e) {
      if (e instanceof EditValidationError) {
        return new Response(`edit rejected: ${e.message}`, { status: 400 });
      }
      throw e;
    }
    const current = (await readCurrentBody(resolved.filePath)) ?? "";
    if (isNoOpEdit(current, body)) {
      // No diff — skip the Forgejo round-trip and bounce back to the
      // form so the user can either change something or cancel.
      return new Response(null, {
        status: 303,
        headers: { Location: `/edit/${resolved.section}/${resolved.slug}` },
      });
    }

    // Git commit FIRST against the local bare repo, then live filesystem
    // write. Git's update-ref gives us free optimistic concurrency
    // (we pass the parent SHA as the expected oldvalue — a concurrent
    // commit fails with kind:"conflict"). Writing FS only after a
    // successful commit avoids the "live but uncommitted" state that
    // would vanish at the next deploy.
    const priorBlobSha = await getFileBlobSha("main", resolved.filePath);
    const outcome: GitCommitOutcome = await commitFile({
      branch: "main",
      path: resolved.filePath,
      content: body,
      priorBlobSha,
      message: buildCommitMessage({
        title: resolved.title,
        author: viewer,
        filePath: resolved.filePath,
      }),
      authorName: viewer,
      authorEmail: noreplyEmail(viewer),
    });
    if (!outcome.ok) {
      // Status 200 (not 5xx): Cloudflare replaces 5xx responses with
      // its own error page, hiding our diagnostic. The HTML body
      // carries the failure semantics; status only affects routing
      // and caching.
      const html = await renderEditCommitFailed(resolved, outcome);
      return htmlResponse(html, outcome.kind === "conflict" ? 409 : 200);
    }
    await applyLiveEdit(resolved, body);
    const html = await renderEditAppliedLive(resolved, outcome);
    return htmlResponse(html);
  }

  const current = (await readCurrentBody(resolved.filePath)) ?? "";
  const html = await renderEditFormPage(resolved, current, viewer);
  return htmlResponse(html);
};