#!/usr/bin/env node // Read-only admin dashboard for the Comiida content pipeline. // Serves at /admin (proxied by Apache). Auth via secret token (?key= or cookie). // Node built-ins only — no external deps. import { createServer } from "node:http"; import { randomUUID } from "node:crypto"; import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs"; import { dirname, resolve, join, extname } from "node:path"; import { fileURLToPath } from "node:url"; import { readFrontmatter, fmString, fmArray, loadJson, todayInTz, addDays, slugify } from "../scripts/lib/util.mjs"; import { addEntry } from "../scripts/lib/calendar.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const PIPELINE = resolve(HERE, ".."); // --- env (.env) --- function loadEnv() { const p = join(PIPELINE, ".env"); if (!existsSync(p)) return; for (const line of readFileSync(p, "utf8").split("\n")) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m && !(m[1] in process.env)) process.env[m[1]] = m[2]; } } loadEnv(); const cfg = loadJson(join(PIPELINE, "config.json")); const root = cfg.paths.projectRoot; const TOKEN = process.env.ADMIN_TOKEN || ""; const PORT = parseInt(process.env.ADMIN_PORT, 10) || 3010; if (!TOKEN) { console.error("[admin] ADMIN_TOKEN not set in .env — refusing to start."); process.exit(1); } // --- data gathering --- const draftsDir = () => join(root, cfg.paths.draftsDir); const blogDir = () => join(root, cfg.paths.blogContentDir); function calendar() { const p = join(root, cfg.paths.calendar); return existsSync(p) ? loadJson(p) : []; } function isPublished(slug) { return existsSync(join(blogDir(), slug, "index.mdx")); } function draftSlugs() { const d = draftsDir(); return existsSync(d) ? readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).map((x) => x.name) : []; } function draftMeta(slug) { const mdx = join(draftsDir(), slug, "index.mdx"); if (!existsSync(mdx)) return null; const fm = readFrontmatter(readFileSync(mdx, "utf8")); let seo = null; const seoPath = join(draftsDir(), slug, "seo-review.json"); if (existsSync(seoPath)) { try { seo = JSON.parse(readFileSync(seoPath, "utf8")); } catch { seo = null; } } return { slug, title: fmString(fm, "title") || slug, category: fmString(fm, "category") || "", tags: fmArray(fm, "tags"), date: fmString(fm, "date") || "", hasCover: existsSync(join(draftsDir(), slug, "cover.jpg")), seo, }; } // --- helpers --- const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); const STATUS_COLORS = { planned: "#8a8f98", drafting: "#d98324", drafted: "#2f6feb", "drafted-no-image": "#b58900", "draft-failed": "#cb2431", published: "#1a7f37", }; const badge = (status) => `${esc(status)}`; const newsBadge = `NEWS`; const suggestedBadge = `SUGGESTED`; function freshNewsCount() { const p = join(root, "agents", "news-queue.json"); if (!existsSync(p)) return 0; try { return JSON.parse(readFileSync(p, "utf8")).filter((i) => i.status === "fresh").length; } catch { return 0; } } const SEO_COLORS = { pass: "#1a7f37", revise: "#d98324", fail: "#cb2431" }; const seoBadge = (seo) => seo ? `SEO ${esc(seo.verdict)} ${esc(seo.overall)}` : `SEO —`; const messagesPath = () => join(root, "agents", "messages.json"); function messages() { const p = messagesPath(); if (!existsSync(p)) return []; try { return JSON.parse(readFileSync(p, "utf8")); } catch { return []; } } function authed(req) { const url = new URL(req.url, "http://x"); const qkey = url.searchParams.get("key"); if (qkey && qkey === TOKEN) return { ok: true, setCookie: true }; const cookie = (req.headers.cookie || "").match(/(?:^|;\s*)admin_key=([^;]+)/); if (cookie && decodeURIComponent(cookie[1]) === TOKEN) return { ok: true }; return { ok: false }; } // --- pages --- function dashboardHtml() { const today = todayInTz(cfg.editorial.timezone); const weekEnd = addDays(today, 6); const cal = calendar().slice().sort((a, b) => (a.date || "").localeCompare(b.date || "")); const drafts = draftSlugs().map(draftMeta).filter(Boolean); const inWeek = cal.filter((e) => e.date >= today && e.date <= weekEnd); const counts = cal.reduce((m, e) => ((m[e.status] = (m[e.status] || 0) + 1), m), {}); const newsSlugs = new Set(cal.filter((e) => e.news).map((e) => e.slug)); drafts.forEach((d) => (d.news = newsSlugs.has(d.slug))); const row = (e) => ` ${esc(e.date)} ${esc(e.workingTitle || e.slug)} ${e.suggested ? suggestedBadge : ""}${e.news ? " " + newsBadge : ""}
${esc(e.primaryKeyword || "")} ${esc(e.type)} ${badge(isPublished(e.slug) ? "published" : e.status)} ${ existsSync(join(draftsDir(), e.slug, "index.mdx")) ? `preview` : isPublished(e.slug) ? `live ↗` : "—" } `; const draftCards = drafts.length ? drafts .map( (d) => `
${d.hasCover ? `` : `
`}
${esc(d.title)} ${d.news ? newsBadge : ""} ${seoBadge(d.seo)}
${esc(d.date)} · ${esc(d.category)} · ${esc(d.tags.join(", "))}
${d.seo?.blocking?.length ? `
⚠ ${esc(d.seo.blocking.length)} blocking: ${esc(d.seo.blocking[0])}${d.seo.blocking.length > 1 ? " …" : ""}
` : ""}
preview · approve.mjs ${esc(d.slug)}
` ) .join("") : `

No drafts awaiting review.

`; const countPills = Object.entries(counts) .map(([k, v]) => `${badge(k)} ${v}`) .join("  "); const msgs = messages().slice().reverse(); const msgHtml = msgs.length ? msgs .map( (m) => `
${esc(m.name)} <${esc(m.email)}> · ${esc((m.at || "").slice(0, 16).replace("T", " "))}
${esc(m.message)}
` ) .join("") : `

No messages yet.

`; return ` Comiida — Content Admin

Comiida — Content Admin

Today ${esc(today)} (${esc(cfg.editorial.timezone)}) · ${countPills} · 📰 news queue: ${freshNewsCount()} fresh

📬 Messages (${msgs.length})

${msgHtml}

This week (${esc(today)} → ${esc(weekEnd)})

${inWeek.length ? `${inWeek.map(row).join("")}
DateTopicTypeStatus
` : `

Nothing scheduled this week. Run research.mjs to plan more.

`}

Drafts awaiting review (${drafts.length})

${draftCards}

Full calendar (${cal.length})

${cal.map(row).join("")}
DateTopicTypeStatus
`; } function draftDetailHtml(slug) { const dir = join(draftsDir(), slug); const mdxPath = join(dir, "index.mdx"); if (!existsSync(mdxPath)) return null; const raw = readFileSync(mdxPath, "utf8"); const fm = readFrontmatter(raw); const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, ""); const sources = existsSync(join(dir, "sources.json")) ? readFileSync(join(dir, "sources.json"), "utf8") : "(none)"; let seo = null; if (existsSync(join(dir, "seo-review.json"))) { try { seo = JSON.parse(readFileSync(join(dir, "seo-review.json"), "utf8")); } catch { seo = null; } } const seoSection = seo ? `

SEO audit ${seoBadge(seo)}

${esc(seo.summary || "")}

${seo.blocking?.length ? `

Blocking:

` : ""} ${seo.topFixes?.length ? `

Top fixes:

` : ""}` : `

SEO audit ${seoBadge(null)}

No audit yet. Run ./run.sh write-daily.mjs (auto-audits) or node scripts/seo-review.mjs ${esc(slug)}.

`; const hasCover = existsSync(join(dir, "cover.jpg")); return ` ${esc(fmString(fm, "title") || slug)} — draft

← back to dashboard

${hasCover ? `` : ""} ${seoSection}

Frontmatter

${esc(fm)}

Body (raw MDX)

${esc(body)}

sources.json

${esc(sources)}

To publish: ./run.sh approve.mjs ${esc(slug)}

`; } // --- server --- const send = (res, code, body, type = "text/html; charset=utf-8", extra = {}) => { res.writeHead(code, { "content-type": type, "cache-control": "no-store", ...extra }); res.end(body); }; const server = createServer((req, res) => { const url = new URL(req.url, "http://x"); const path = url.pathname.replace(/\/+$/, "") || "/admin"; // PUBLIC (no auth): contact form submission → saved to messages.json, viewed in /admin. if (req.method === "POST" && path === "/contact-submit") { let body = ""; req.on("data", (c) => { body += c; if (body.length > 20000) req.destroy(); // basic size guard }); req.on("end", () => { try { const d = JSON.parse(body || "{}"); if (d.website) return send(res, 200, JSON.stringify({ ok: true }), "application/json"); // honeypot → silently drop const name = String(d.name || "").trim().slice(0, 120); const email = String(d.email || "").trim().slice(0, 160); const message = String(d.message || "").trim().slice(0, 4000); if (!name || !message || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { return send(res, 400, JSON.stringify({ error: "Please fill in your name, a valid email, and a message." }), "application/json"); } const arr = messages(); arr.push({ id: randomUUID(), at: new Date().toISOString(), name, email, message }); writeFileSync(messagesPath(), JSON.stringify(arr, null, 2)); send(res, 200, JSON.stringify({ ok: true }), "application/json"); } catch (e) { send(res, 500, JSON.stringify({ error: e.message }), "application/json"); } }); return; } const auth = authed(req); if (!auth.ok) { return send(res, 401, "

401

Add ?key=YOUR_TOKEN to the URL.

"); } const cookieHeader = auth.setCookie ? { "set-cookie": `admin_key=${encodeURIComponent(TOKEN)}; Path=/admin; HttpOnly; SameSite=Lax; Max-Age=2592000` } : {}; // suggest an article (POST) — inject a top-priority calendar entry if (req.method === "POST" && path === "/admin/suggest") { let body = ""; req.on("data", (c) => (body += c)); req.on("end", async () => { try { const data = JSON.parse(body || "{}"); const title = String(data.title || "").trim(); if (!title) return send(res, 400, JSON.stringify({ error: "Title is required" }), "application/json"); const description = String(data.description || "").trim(); const source = String(data.source || "").trim(); const instructions = String(data.instructions || "").trim().slice(0, 4000); const draft = String(data.draft || "").trim().slice(0, 20000); const today = todayInTz(cfg.editorial.timezone); const entry = { date: today, slug: slugify(title), workingTitle: title, type: "guide", primaryKeyword: title, secondaryKeywords: [], searchIntent: "informational", audienceAngle: description, sourceHints: source ? [source] : [], eeatAngle: "User-suggested topic; research thoroughly and cite every claim.", suggested: true, status: "planned", }; if (instructions) entry.instructions = instructions; if (draft) entry.draft = draft; await addEntry(join(root, cfg.paths.calendar), entry); send(res, 200, JSON.stringify({ ok: true, slug: entry.slug }), "application/json", cookieHeader); } catch (e) { send(res, 500, JSON.stringify({ error: e.message }), "application/json"); } }); return; } // delete a message (token-gated) if (req.method === "POST" && path === "/admin/message-delete") { let body = ""; req.on("data", (c) => (body += c)); req.on("end", () => { try { const { id } = JSON.parse(body || "{}"); const arr = messages().filter((m) => (m.id || m.at) !== id); writeFileSync(messagesPath(), JSON.stringify(arr, null, 2)); send(res, 200, JSON.stringify({ ok: true }), "application/json", cookieHeader); } catch (e) { send(res, 500, JSON.stringify({ error: e.message }), "application/json"); } }); return; } // cover image const cover = path.match(/^\/admin\/cover\/([a-z0-9-]+)$/i); if (cover) { const f = join(draftsDir(), cover[1], "cover.jpg"); if (!existsSync(f)) return send(res, 404, "not found", "text/plain"); return send(res, 200, readFileSync(f), "image/jpeg", cookieHeader); } // draft detail const draft = path.match(/^\/admin\/draft\/([a-z0-9-]+)$/i); if (draft) { const html = draftDetailHtml(draft[1]); return html ? send(res, 200, html, "text/html; charset=utf-8", cookieHeader) : send(res, 404, "

404

"); } // dashboard if (path === "/admin" || path === "/admin/") { return send(res, 200, dashboardHtml(), "text/html; charset=utf-8", cookieHeader); } return send(res, 404, "

404

"); }); server.listen(PORT, "127.0.0.1", () => console.log(`[admin] listening on 127.0.0.1:${PORT}`));