#!/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) => `
approve.mjs ${esc(d.slug)}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) => `No messages yet.
`; return `| Date | Topic | Type | Status |
|---|
Nothing scheduled this week. Run research.mjs to plan more.
`}| Date | Topic | Type | Status |
|---|
${esc(seo.summary || "")}
Blocking:
Top fixes:
No audit yet. Run ./run.sh write-daily.mjs (auto-audits) or node scripts/seo-review.mjs ${esc(slug)}.
${esc(fm)}
${esc(body)}
${esc(sources)}
To publish: ./run.sh approve.mjs ${esc(slug)}
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, "