/** * Admin route handlers. * * Everything here is form-based so a JS-off browser still works. The one GET * that mutates state (the login POST → session cookie) is a form submission; * every other mutation POSTs and redirects to the GET it changed. * * Pages come from admin-pages.js; this file is the glue between HTTP and the * DB / session. */ import { layout } from './layout.js'; import { newChallenge, verifyLogin, readSession, clearCookie, adminPublisher, loginMessage, } from './admin-session.js'; import { allListingsIncludingWithdrawn, allActiveListings, withdrawListing, getListing, countActive, recentAudit as recentAuditRows, } from './db.js'; import { allModes, setMode, MODES } from './modes.js'; import { CATEGORIES, countsByCategory } from './pages-browse.js'; import { logAudit } from './audit.js'; import { loginPage, dashboardPage, listingsPage, modesPage, auditPage, } from './admin-pages.js'; /* ----------------- dispatch (called from server.js) ----------------- */ /** * Admin router. Returns true if it handled the request. The caller falls * through to the regular router if we return false. */ export async function handleAdmin(req, res, url) { // /admin/login (GET + POST) is the only path accessible without a session. if (url === '/admin/login') { if (req.method === 'POST') return doLogin(req, res); return renderPage(res, loginPage({ challenge: newChallenge(adminPublisher()), name: adminPublisher() || '(ADMIN_PUBLISHER env not set)', })); } if (url === '/admin/logout' && req.method === 'POST') { return doLogout(res); } // Everything else requires a session. const session = readSession(req); if (!session) { res.writeHead(302, { location: '/admin/login' }); return res.end(); } if (url === '/admin' || url === '/admin/') return showDashboard(res, session); if (url === '/admin/listings') return showListings(res, session); if (url === '/admin/modes') { if (req.method === 'POST') return saveModes(req, res, session); return showModes(res, session); } if (url === '/admin/audit') return showAudit(res, session); const withdrawMatch = url.match(/^\/admin\/listings\/([^/]+)\/([^/]+)\/withdraw$/); if (withdrawMatch && req.method === 'POST') { return doWithdraw(req, res, session, `${decodeURIComponent(withdrawMatch[1])}/${decodeURIComponent(withdrawMatch[2])}`); } return notFoundHtml(res); } /* ----------------- login + logout ----------------- */ async function doLogin(req, res) { const form = await readForm(req); const result = await verifyLogin({ challenge: String(form.challenge || ''), signature: String(form.signature || ''), }); if (!result.ok) { return renderPage(res, loginPage({ challenge: newChallenge(adminPublisher()), name: adminPublisher(), error: result.reason, }), 400); } logAudit({ actor: result.admin.name, action: 'login' }); res.writeHead(302, { location: '/admin', 'set-cookie': result.cookie, }); res.end(); } function doLogout(res) { res.writeHead(302, { location: '/admin/login', 'set-cookie': clearCookie(), }); res.end(); } /* ----------------- GET pages ----------------- */ function showDashboard(res, session) { const counts = countsByCategory(); const total = countActive(); const modes = allModes(CATEGORIES.map((c) => c.slug)); const recentListings = allActiveListings().slice(0, 10); const recentAudit = recentAuditRows(20); renderPage(res, dashboardPage({ session, counts, total, modes, recentListings, recentAudit })); } function showListings(res, session) { const rows = allListingsIncludingWithdrawn(); renderPage(res, listingsPage({ session, rows })); } function showModes(res, session) { const modes = allModes(CATEGORIES.map((c) => c.slug)); renderPage(res, modesPage({ session, modes })); } function showAudit(res, session) { const entries = recentAuditRows(200); renderPage(res, auditPage({ session, entries })); } /* ----------------- POST actions ----------------- */ async function doWithdraw(req, res, session, id) { const row = getListing(id); if (!row) return notFoundHtml(res); const changed = withdrawListing(id, new Date().toISOString()); if (changed) { logAudit({ actor: session.name, action: 'withdraw_listing', target: id, details: { category: row.category, publisher: row.publisher }, }); } res.writeHead(302, { location: '/admin/listings' }); res.end(); } async function saveModes(req, res, session) { const form = await readForm(req); // Global first — the per-category reads depend on the current global. const g = String(form.global || ''); if (MODES.includes(g)) { setMode({ category: null, value: g, actor: session.name }); } for (const c of CATEGORIES) { const v = String(form[`cat_${c.slug}`] || ''); // v === '' means "inherits global" — pass empty value to clear override. setMode({ category: c.slug, value: v, actor: session.name }); } res.writeHead(302, { location: '/admin/modes' }); res.end(); } /* ----------------- helpers ----------------- */ function renderPage(res, page, status = 200) { const html = layout(page); res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', }); res.end(html); } function notFoundHtml(res) { const page = { title: 'Not here', description: 'The requested admin page does not exist.', body: `

Not here

Go home.

`, }; renderPage(res, page, 404); } async function readForm(req) { const chunks = []; let total = 0; for await (const chunk of req) { total += chunk.length; if (total > 64 * 1024) throw new Error('form too large'); chunks.push(chunk); } const body = Buffer.concat(chunks).toString('utf8'); const params = new URLSearchParams(body); const out = {}; for (const [k, v] of params) out[k] = v; return out; }