/**
* 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, paymentsPage,
} from './admin-pages.js';
import { allInvoices } from './db.js';
import {
manuallyMarkPaid, LISTING_FEE_USD, RECEIVE_ADDRESS, paymentsRequired,
} from './payments.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);
if (url === '/admin/payments') return showPayments(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])}`);
}
const markPaidMatch = url.match(/^\/admin\/payments\/([^/]+)\/mark-paid$/);
if (markPaidMatch && req.method === 'POST') {
return doMarkPaid(req, res, session, decodeURIComponent(markPaidMatch[1]));
}
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 }));
}
function showPayments(res, session) {
const invoices = allInvoices();
const config = {
required: paymentsRequired(),
fee_usd: LISTING_FEE_USD,
address: RECEIVE_ADDRESS,
};
renderPage(res, paymentsPage({ session, invoices, config }));
}
/* ----------------- 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 doMarkPaid(req, res, session, invoiceId) {
const form = await readForm(req);
try {
const invoice = manuallyMarkPaid(invoiceId, { txid: form.txid || '', actor: session.name });
logAudit({
actor: session.name,
action: 'mark_invoice_paid',
target: invoiceId,
details: { listing_id: invoice.listing_id, txid: form.txid || null, amount_sat: invoice.amount_sat },
});
} catch (err) {
// Fall through to the redirect; the admin can retry from the UI.
}
res.writeHead(302, { location: '/admin/payments' });
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.