// 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 => { 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 => { 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 => { 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); };