syntaxai/tdd.md · main · src / d21_handlers_edit.ts
// 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);
};