// c21 — handlers: CRUD on sxdoc-backed pages + posts. // // Composes: // c13_database listDocuments / loadDocument / saveDocument / deleteDocument // c32_session getViewer (admin gate) // c31_sxdoc_parse htmlToSx (parse posted HTML → SxDocument) // c51_render_sxdoc sxToHtml (project stored doc back to HTML for the form) // c31_admin_validation validateEditForm (form → typed input) // c51_render_admin shell rendering // // Routes (mounted in c21_app.ts): // GET /admin // GET /admin/new // POST /admin/new // GET /admin/edit/:type/:slug // POST /admin/edit/:type/:slug // POST /admin/delete/:type/:slug // // Auth: any non-admin signed-in viewer → 403 wall (matches the legacy // /edit handler). Anonymous → 401 login wall. import { ADMIN_USERNAME } from "./a31_site_config.ts"; import { listDocuments, loadDocument, saveDocument, deleteDocument, } from "./c13_database.ts"; import { getViewer } from "./b32_session.ts"; import { htmlToSx } from "./a31_sxdoc_parse.ts"; import { validateEditForm } from "./a31_admin_validation.ts"; import { htmlResponse } from "./b51_render_layout.ts"; import { renderAdminList, renderAdminEdit, renderAdminLoginWall, renderAdminNonAdminWall, } from "./b51_render_admin.ts"; const wantsJson = (req: Request): boolean => (req.headers.get("accept") ?? "").includes("application/json"); const jsonResponse = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", }, }); // ─── auth gate ─────────────────────────────────────────────────────────── interface AuthOk { ok: true; viewer: string; } interface AuthDenied { ok: false; response: Response; } type AuthResult = AuthOk | AuthDenied; const requireAdmin = async (req: Request): Promise => { const viewer = await getViewer(req); if (!viewer) { const html = await renderAdminLoginWall(); return { ok: false, response: htmlResponse(html, 401) }; } if (viewer !== ADMIN_USERNAME) { const html = await renderAdminNonAdminWall(viewer); return { ok: false, response: htmlResponse(html, 403) }; } return { ok: true, viewer }; }; // FormData → string-record adapter. The validator lives in c31 and // stays browser-agnostic by taking plain string fields. const formToRecord = async (req: Request): Promise> => { const fd = await req.formData(); const out: Record = {}; for (const [k, v] of fd.entries()) out[k] = String(v); return out; }; // ─── handlers ──────────────────────────────────────────────────────────── export const adminListHandler = async (req: Request): Promise => { const auth = await requireAdmin(req); if (!auth.ok) return auth.response; const documents = listDocuments(); const html = await renderAdminList(documents); return htmlResponse(html); }; export const adminNewHandler = async (req: Request): Promise => { const auth = await requireAdmin(req); if (!auth.ok) return auth.response; const json = wantsJson(req); if (req.method === "POST") { const form = await formToRecord(req); const v = validateEditForm(form); if (!v.ok) { if (json) return jsonResponse({ ok: false, error: v.error }, 400); const html = await renderAdminEdit({ mode: "new", title: form.title ?? "", slug: form.slug ?? "", type: form.type === "post" ? "post" : "page", doc: htmlToSx(form.html ?? ""), status: form.status === "draft" ? "draft" : "published", primaryTag: (form.primary_tag ?? "").trim() || null, error: v.error, }); return htmlResponse(html, 400); } if (loadDocument(v.data.slug, v.data.type)) { const err = `a ${v.data.type} with slug "${v.data.slug}" already exists`; if (json) return jsonResponse({ ok: false, error: err }, 409); const html = await renderAdminEdit({ mode: "new", title: v.data.title, slug: v.data.slug, type: v.data.type, doc: htmlToSx(v.data.html), status: v.data.status, primaryTag: v.data.primaryTag, error: err, }); return htmlResponse(html, 409); } saveDocument({ slug: v.data.slug, type: v.data.type, title: v.data.title, doc: htmlToSx(v.data.html), status: v.data.status, primaryTag: v.data.primaryTag, }); if (json) { return jsonResponse({ ok: true, ts: Date.now(), slug: v.data.slug, type: v.data.type }); } return new Response(null, { status: 303, headers: { Location: `/admin/edit/${v.data.type}/${v.data.slug}` }, }); } // GET — empty form const html = await renderAdminEdit({ mode: "new", title: "", slug: "", type: "page", doc: htmlToSx("

Hello, world.

"), status: "published", primaryTag: null, }); return htmlResponse(html); }; export const adminEditHandler = async ( req: Request & { params: { type: string; slug: string } }, ): Promise => { const auth = await requireAdmin(req); if (!auth.ok) return auth.response; const type = req.params.type === "post" ? "post" : "page"; if (req.params.type !== "page" && req.params.type !== "post") { return new Response("invalid type", { status: 400 }); } const slug = req.params.slug; const existing = loadDocument(slug, type); if (!existing) return new Response("not found", { status: 404 }); if (req.method === "POST") { const form = await formToRecord(req); const json = wantsJson(req); const v = validateEditForm(form); if (!v.ok) { if (json) return jsonResponse({ ok: false, error: v.error }, 400); const html = await renderAdminEdit({ mode: "edit", title: form.title ?? existing.title, slug: form.slug ?? slug, type, doc: htmlToSx(form.html ?? ""), status: form.status === "draft" ? "draft" : "published", primaryTag: (form.primary_tag ?? "").trim() || existing.primaryTag, error: v.error, }); return htmlResponse(html, 400); } // Rename (slug or type changed) — reject collision with another // existing doc; otherwise delete the old key before saving the new one. if (v.data.slug !== slug || v.data.type !== type) { const collision = loadDocument(v.data.slug, v.data.type); if (collision && collision.id !== existing.id) { const err = `a ${v.data.type} with slug "${v.data.slug}" already exists`; if (json) return jsonResponse({ ok: false, error: err }, 409); const html = await renderAdminEdit({ mode: "edit", title: v.data.title, slug: v.data.slug, type: v.data.type, doc: htmlToSx(v.data.html), status: v.data.status, primaryTag: v.data.primaryTag, error: err, }); return htmlResponse(html, 409); } deleteDocument(slug, type); } saveDocument({ slug: v.data.slug, type: v.data.type, title: v.data.title, doc: htmlToSx(v.data.html), status: v.data.status, primaryTag: v.data.primaryTag, }); if (json) { return jsonResponse({ ok: true, ts: Date.now(), slug: v.data.slug, type: v.data.type }); } return new Response(null, { status: 303, headers: { Location: `/admin/edit/${v.data.type}/${v.data.slug}` }, }); } // GET — render the stored sxdoc directly; c51_render_admin computes // the textarea HTML projection and embeds the JSON for client hydration. const html = await renderAdminEdit({ mode: "edit", title: existing.title, slug: existing.slug, type: existing.type, doc: existing.doc, status: existing.status, primaryTag: existing.primaryTag, }); return htmlResponse(html); }; export const adminDeleteHandler = async ( req: Request & { params: { type: string; slug: string } }, ): Promise => { const auth = await requireAdmin(req); if (!auth.ok) return auth.response; if (req.method !== "POST") return new Response("POST only", { status: 405 }); const type = req.params.type === "post" ? "post" : "page"; if (req.params.type !== "page" && req.params.type !== "post") { return new Response("invalid type", { status: 400 }); } deleteDocument(req.params.slug, type); return new Response(null, { status: 303, headers: { Location: "/admin" } }); };